diff --git a/.vex.toml b/.vex.toml index 210a51a..2d4f9b3 100644 --- a/.vex.toml +++ b/.vex.toml @@ -19,6 +19,7 @@ semantic = true # Automatically run `vex update` before search if the index is stale auto_update = true -# Embedder used for semantic indexing. Known IDs: minilm-l6-v2 (default). +# Embedder used for semantic indexing. IDs: minilm-l6-v2 (default, CPU-fast), +# jina-code (code-specialized, GPU-worthy), bge-base-en-v1.5, bge-large-en-v1.5. # Changing the embedder requires a full reindex. -# embedder = "minilm-l6-v2" +embedder = "jina-code" diff --git a/README.md b/README.md index 74908c1..14b37a9 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,19 @@ LedGrab speaks many protocols, so a single setup can drive everything from a DIY - Real-time FPS, latency, and uptime charts - Localized in English, Russian, and Chinese +### Activity Log + +The **Activity** tab is a persistent, queryable audit log of everything LedGrab has done — entity changes, auth events, device connections, and system actions. + +- Filter by category (auth, device, entity, capture, system), severity, actor, entity type, date range, or free text +- Live-append of new events as they happen +- Export as CSV or JSON (authentication required) +- Entity crosslinks navigate directly to the relevant card +- **Retention settings** (Settings → Activity Log): configure max age, max entry count, and toggle recording on/off +- **Clear log** (Settings → Activity Log, requires authentication) — audited: a system entry records who cleared the log and when + +> **Note:** The Activity Log is distinct from the **debug Log Viewer** (Settings → General → Open Log Viewer). The Log Viewer is an ephemeral real-time tail of the server's Python log stream (WARNING/ERROR lines, resets on disconnect). The Activity Log is a structured, persistent SQLite-backed record of semantic application events. + ### Home Assistant Integration - HACS-compatible custom component (separate repository) diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 6f920a5..4327d64 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -210,6 +210,10 @@ dependencies { implementation("androidx.core:core-splashscreen:1.0.1") // QR code generation for displaying server URL on TV implementation("com.google.zxing:core:3.5.3") + // EncryptedSharedPreferences (Android Keystore-backed) for the per-install + // server API key (see ApiKeyManager). Falls back to plain SharedPreferences + // when the keystore is unavailable. + implementation("androidx.security:security-crypto:1.1.0-alpha06") // USB-serial drivers (CH340, CP2102, FTDI, Prolific, CDC-ACM) for // driving Adalight/AmbiLED controllers plugged into Android TV boxes. implementation("com.github.mik3y:usb-serial-for-android:3.8.1") diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 0417e90..249c233 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -14,7 +14,8 @@ - + + // Best-effort persist into the encrypted store; cache regardless + // so we still return the recovered key if the write keeps failing. + runCatching { prefs.edit().putString(KEY_API_KEY, recovered).commit() } + cached = recovered + Log.i(TAG, "Recovered existing API key from legacy storage") + return recovered + } val generated = generateKey() // commit() (synchronous disk write) on the FIRST write so // the key is durable before MainActivity encodes it into a @@ -74,6 +106,88 @@ class ApiKeyManager(context: Context) { } } + /** + * Build the backing store, preferring EncryptedSharedPreferences. Returns + * (store, isEncrypted). Any keystore failure falls back to the plain prefs + * file so the local API key is never lost on a broken-keystore device. + */ + private fun buildPrefs(context: Context): Pair { + return try { + val masterKey = MasterKey.Builder(context) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() + val store = EncryptedSharedPreferences.create( + context, + ENCRYPTED_PREFS_NAME, + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, + ) + store to true + } catch (e: Exception) { + // Keystore unavailable/corrupt — degrade to plain prefs rather + // than crashing. Worst case the key is stored unencrypted on a + // single-user TV box, which is the pre-existing behaviour. + Log.w(TAG, "EncryptedSharedPreferences unavailable, using plain prefs: ${e.message}") + context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) to false + } + } + + /** + * One-time migration: if a key exists in the legacy plain-text prefs file + * (from before encrypted storage), copy it into the encrypted store and + * remove the plain copy. Preserves the existing key so already-scanned QR + * clients keep working — generating a fresh key here would silently 401 + * every LAN client (see the Data Migration Policy in CLAUDE.md). + */ + private fun migrateLegacyKeyIfPresent() { + // Don't migrate if the encrypted store already holds a key. + if (!prefs.getString(KEY_API_KEY, null).isNullOrEmpty()) return + runCatching { + val legacy = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + val legacyKey = legacy.getString(KEY_API_KEY, null) + if (legacyKey != null && legacyKey.length >= MIN_KEY_LENGTH) { + // commit() returns false on write failure WITHOUT throwing, so the + // runCatching wrapper alone does NOT protect this path. Verify the + // encrypted store both committed AND reads back the identical value + // before touching the legacy copy — otherwise a silent write + // failure could delete the only surviving copy of the key and + // rotate it on next launch (401s every paired client — the exact + // silent-data-loss the Data Migration Policy forbids). + val ok = prefs.edit().putString(KEY_API_KEY, legacyKey).commit() + if (ok && prefs.getString(KEY_API_KEY, null) == legacyKey) { + // Keep the value as a .migrated backup (don't hard-delete) per + // the migration policy; remove only the live legacy key so the + // plaintext copy no longer answers reads. + legacy.edit() + .putString(KEY_API_KEY_MIGRATED, legacyKey) + .remove(KEY_API_KEY) + .apply() + Log.i(TAG, "Migrated API key from plain to encrypted storage") + } else { + // Leave the legacy key untouched; getOrCreateKey() will recover + // it via recoverLegacyKey() rather than minting a fresh one. + Log.w(TAG, "Encrypted key write unverified — keeping legacy key, not migrating") + } + } + }.onFailure { Log.w(TAG, "Legacy API key migration failed: ${it.message}") } + } + + /** + * Recover a still-present key from the legacy plain store — either the live + * key (failed/never-run migration) or the `.migrated` backup. Returns null + * when on the plain-prefs path (no legacy/encrypted split) or no valid key + * survives. Guarantees [getOrCreateKey] never rotates an existing key as long + * as the legacy file survives. + */ + private fun recoverLegacyKey(): String? { + if (!encrypted) return null + val legacy = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + val candidate = legacy.getString(KEY_API_KEY, null) + ?: legacy.getString(KEY_API_KEY_MIGRATED, null) + return candidate?.takeIf { it.length >= MIN_KEY_LENGTH } + } + private fun generateKey(): String { val bytes = ByteArray(KEY_BYTES) SecureRandom().nextBytes(bytes) @@ -88,7 +202,11 @@ class ApiKeyManager(context: Context) { companion object { private const val TAG = "ApiKeyManager" private const val PREFS_NAME = "ledgrab_auth" + private const val ENCRYPTED_PREFS_NAME = "ledgrab_auth_enc" private const val KEY_API_KEY = "api_key" + // Backup of a migrated legacy key, kept in the plain store per the + // Data Migration Policy (never hard-delete user data on rename/move). + private const val KEY_API_KEY_MIGRATED = "api_key_migrated" private const val KEY_BYTES = 32 private const val MIN_KEY_LENGTH = 32 diff --git a/android/app/src/main/java/com/ledgrab/android/BleBridge.kt b/android/app/src/main/java/com/ledgrab/android/BleBridge.kt index 71aa5de..06f81cb 100644 --- a/android/app/src/main/java/com/ledgrab/android/BleBridge.kt +++ b/android/app/src/main/java/com/ledgrab/android/BleBridge.kt @@ -103,12 +103,32 @@ object BleBridge { } try { - bleHandler.post { scanner.startScan(callback) } + // startScan runs on the BLE handler thread; a denied + // BLUETOOTH_SCAN throws SecurityException there, which would + // crash the whole process (an uncaught exception on a handler + // thread is fatal). Catch it inside the posted body and report. + bleHandler.post { + try { + scanner.startScan(callback) + } catch (e: SecurityException) { + Log.w(TAG, "BLUETOOTH_SCAN permission denied — scan skipped", e) + } catch (e: Exception) { + Log.w(TAG, "BLE startScan failed: ${e.message}") + } + } Thread.sleep(timeoutMs) } catch (_: InterruptedException) { Thread.currentThread().interrupt() } finally { - try { bleHandler.post { scanner.stopScan(callback) } } catch (_: SecurityException) {} + bleHandler.post { + try { + scanner.stopScan(callback) + } catch (e: SecurityException) { + Log.w(TAG, "BLUETOOTH_SCAN permission denied — stopScan skipped", e) + } catch (e: Exception) { + Log.w(TAG, "BLE stopScan failed: ${e.message}") + } + } } return seen.values.toList() } @@ -136,7 +156,18 @@ object BleBridge { newState == BluetoothProfile.STATE_CONNECTED && status == BluetoothGatt.GATT_SUCCESS -> { Log.d(TAG, "GATT connected to $address, discovering services") - gatt.discoverServices() + // Runs on the BLE handler thread; a denied + // BLUETOOTH_CONNECT throws SecurityException here, which + // would crash the process. Catch and fail the connect. + try { + gatt.discoverServices() + } catch (e: SecurityException) { + Log.e(TAG, "BLUETOOTH_CONNECT denied during discoverServices", e) + readyDeferred.complete(false) + } catch (e: Exception) { + Log.w(TAG, "discoverServices failed: ${e.message}") + readyDeferred.complete(false) + } } newState == BluetoothProfile.STATE_DISCONNECTED -> { Log.w(TAG, "GATT disconnected from $address (status=$status)") diff --git a/android/app/src/main/java/com/ledgrab/android/CaptureService.kt b/android/app/src/main/java/com/ledgrab/android/CaptureService.kt index a6e0c07..0252f32 100644 --- a/android/app/src/main/java/com/ledgrab/android/CaptureService.kt +++ b/android/app/src/main/java/com/ledgrab/android/CaptureService.kt @@ -105,12 +105,48 @@ class CaptureService : Service() { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { val useRoot = intent?.getBooleanExtra(EXTRA_USE_ROOT, false) ?: false - // CRITICAL: startForeground must be called IMMEDIATELY — before - // any other work, especially before getMediaProjection(). The - // service type must match the work; pass it explicitly via - // ServiceCompat so we stay compatible back to API 24. + // CRITICAL (Android 14+): for the MediaProjection path, validate the + // projection token BEFORE promoting to a foreground service with the + // mediaProjection FGS type. On service recreation (system redelivery + // or a stale relaunch) the consent token is gone — promoting first and + // then discovering the dead token causes a spurious foreground-service + // start + immediate stop, which on strict OEMs flickers the + // notification or trips a stopSelf loop. Bail out cleanly here, before + // startForeground, when the MediaProjection consent data is missing. val localIp = NetworkUtils.getLocalIpAddress(this) ?: "—" val url = "http://$localIp:$SERVER_PORT" + + val mediaProjectionResultData: Intent? = + if (!useRoot) extractProjectionResultData(intent) else null + if (!useRoot && (intent == null || mediaProjectionResultData == null)) { + // MediaProjection mode can't recover from a redelivery — + // the consent token in the original intent is single-use. + // + // We were launched via startForegroundService(), so the OS REQUIRES + // a startForeground() within ~5s even on this immediate-stop path, + // or it raises the fatal ForegroundServiceDidNotStartInTimeException. + // Promote with a benign SPECIAL_USE type (NOT mediaProjection — we + // have no valid consent token, and requesting that type without an + // active projection is exactly what we're avoiding) just long enough + // to satisfy the contract, then stop. + Log.w(TAG, "MediaProjection start without a valid consent token — stopping") + runCatching { + val bailType = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE + } else { + 0 + } + ServiceCompat.startForeground(this, NOTIFICATION_ID, buildNotification(url), bailType) + }.onFailure { Log.w(TAG, "Bail-path startForeground failed: ${it.message}") } + stopSelf() + return START_NOT_STICKY + } + + // startForeground must be called IMMEDIATELY after the token check — + // before any heavier work like getMediaProjection(). The service type + // must match the work; pass it explicitly via ServiceCompat so we stay + // compatible back to API 24. The MEDIA_PROJECTION type is only used + // here once resultData is confirmed non-null (checked above). try { val type = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { var t = if (useRoot) { @@ -152,20 +188,13 @@ class CaptureService : Service() { // otherwise `isRunning=true` sticks forever when startForeground throws. isRunning = true - if (intent == null && !useRoot) { - // MediaProjection mode can't recover from a redelivery — - // the consent token in the original intent is single-use. - Log.w(TAG, "Service restarted without intent (MediaProjection mode) — stopping") - isRunning = false - stopSelf() - return START_NOT_STICKY - } - try { if (useRoot) { startRootCapture(url) } else { - startMediaProjectionCapture(intent!!, url) + // mediaProjectionResultData is guaranteed non-null here — the + // token was validated before startForeground above. + startMediaProjectionCapture(intent!!, mediaProjectionResultData!!, url) } } catch (e: Exception) { Log.e(TAG, "Failed to start capture", e) @@ -294,20 +323,24 @@ class CaptureService : Service() { } } - private fun startMediaProjectionCapture(intent: Intent, url: String) { - val resultCode = intent.getIntExtra(EXTRA_RESULT_CODE, 0) + /** + * Extract the single-use MediaProjection consent token from the start + * intent, or null if the intent is missing/redelivered without it. + * Called BEFORE startForeground so the mediaProjection FGS type is only + * ever requested when a valid token is present (see onStartCommand). + */ + private fun extractProjectionResultData(intent: Intent?): Intent? { + if (intent == null) return null @Suppress("DEPRECATION") - val resultData = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { intent.getParcelableExtra(EXTRA_RESULT_DATA, Intent::class.java) } else { intent.getParcelableExtra(EXTRA_RESULT_DATA) } + } - if (resultData == null) { - Log.e(TAG, "No MediaProjection result data") - stopSelf() - return - } + private fun startMediaProjectionCapture(intent: Intent, resultData: Intent, url: String) { + val resultCode = intent.getIntExtra(EXTRA_RESULT_CODE, 0) val projectionManager = getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager diff --git a/android/app/src/main/java/com/ledgrab/android/LedGrabNotificationListener.kt b/android/app/src/main/java/com/ledgrab/android/LedGrabNotificationListener.kt index ea79608..14f92bb 100644 --- a/android/app/src/main/java/com/ledgrab/android/LedGrabNotificationListener.kt +++ b/android/app/src/main/java/com/ledgrab/android/LedGrabNotificationListener.kt @@ -6,7 +6,9 @@ import android.service.notification.StatusBarNotification import android.util.Log import com.chaquo.python.Python import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.ExecutorService import java.util.concurrent.Executors +import java.util.concurrent.RejectedExecutionException /** * Captures posted OS notifications and forwards the posting app's display @@ -25,7 +27,20 @@ class LedGrabNotificationListener : NotificationListenerService() { // Serial executor: the Python receiver does a (non-concurrency-safe) history // disk write and may play a sound, so pushes must not overlap. Off the main // looper to keep the system service responsive. - private val pushExecutor = Executors.newSingleThreadExecutor() + // + // Tied to the listener-connection lifecycle (onListenerConnected / + // onListenerDisconnected), NOT onDestroy: this is a system-rebindable + // service, so it can be connected/disconnected multiple times across a + // single onCreate..onDestroy span. Managing the executor here — combined + // with the runCatching guard at the submit site — keeps a notification + // that races teardown from triggering RejectedExecutionException on a + // shut-down executor. @Volatile so the connect/disconnect callbacks (which + // may run on a different thread than onNotificationPosted) publish safely. + @Volatile private var pushExecutor: ExecutorService? = null + + // Guards executor creation so the lazy submit-site fallback and + // onListenerConnected can't race two executors into existence. + private val executorLock = Any() // packageName -> resolved human-readable label. Matches the app_name the // Windows/Linux backends pass, so per-app colors/filters keep working. @@ -51,17 +66,34 @@ class LedGrabNotificationListener : NotificationListenerService() { val label = resolveAppLabel(notification.packageName) - pushExecutor.execute { - try { - Python.getInstance() - .getModule(PY_MODULE) - .callAttr("push_notification", label) - } catch (t: Throwable) { - // Never crash a system-bound service. Python.getInstance() throws - // IllegalStateException if Python.start() hasn't run (e.g. the - // service was bound at boot before the app process initialized). - // Log at debug — the label is potentially sensitive on a shared TV. - Log.d(TAG, "push_notification failed: ${t.message}") + // Obtain (creating if needed) the executor. onListenerConnected normally + // creates it, but that callback is not reliably invoked on every + // OEM/version (re)bind, and a notification can arrive before it fires — + // lazily creating here keeps a missing/late onListenerConnected from + // permanently disabling notification forwarding. A late submit onto an + // executor that onListenerDisconnected is shutting down throws + // RejectedExecutionException — guard with runCatching so a notification + // racing teardown can never crash this system-bound service. + val executor = ensureExecutor() + runCatching { + executor.execute { + try { + Python.getInstance() + .getModule(PY_MODULE) + .callAttr("push_notification", label) + } catch (t: Throwable) { + // Never crash a system-bound service. Python.getInstance() throws + // IllegalStateException if Python.start() hasn't run (e.g. the + // service was bound at boot before the app process initialized). + // Log at debug — the label is potentially sensitive on a shared TV. + Log.d(TAG, "push_notification failed: ${t.message}") + } + } + }.onFailure { e -> + if (e is RejectedExecutionException) { + Log.d(TAG, "push rejected — listener disconnecting") + } else { + throw e } } } @@ -69,24 +101,59 @@ class LedGrabNotificationListener : NotificationListenerService() { /** Resolve (and cache) a package's human-readable label; fall back to the package name. */ private fun resolveAppLabel(pkg: String): String { labelCache[pkg]?.let { return it } + // Only cache SUCCESSFUL resolutions. Caching the package-name fallback + // would permanently pin a wrong label if the PackageManager lookup + // failed transiently (e.g. the app was mid-install / still updating). val resolved = runCatching { val info = packageManager.getApplicationInfo(pkg, 0) packageManager.getApplicationLabel(info).toString() - }.getOrDefault(pkg) - labelCache[pkg] = resolved - return resolved + }.getOrNull() + if (resolved != null) { + labelCache[pkg] = resolved + return resolved + } + return pkg + } + + /** + * Return the push executor, creating it under [executorLock] if absent. + * Safe against a concurrent onListenerConnected/onNotificationPosted race + * (single executor) and against a missing onListenerConnected callback. + */ + private fun ensureExecutor(): ExecutorService { + pushExecutor?.let { return it } + synchronized(executorLock) { + return pushExecutor ?: Executors.newSingleThreadExecutor().also { pushExecutor = it } + } } override fun onListenerConnected() { Log.i(TAG, "Notification listener connected") + // Spin up the push executor on connect. The system can disconnect and + // later reconnect this service without destroying it, so own the + // executor here rather than in onCreate/onDestroy. onNotificationPosted + // also lazily creates it (via ensureExecutor) in case this callback is + // late or skipped on some ROMs. + ensureExecutor() } override fun onListenerDisconnected() { Log.i(TAG, "Notification listener disconnected") + // Tear the executor down on disconnect; a fresh one is created on the + // next onListenerConnected. Null out first so any in-flight + // onNotificationPosted snapshots see null (skips submit) rather than + // racing a shutdown executor. + pushExecutor?.let { exec -> + pushExecutor = null + exec.shutdown() + } } override fun onDestroy() { - pushExecutor.shutdown() + // Defensive: onListenerDisconnected normally clears this first, but + // shut down here too in case onDestroy fires without a prior disconnect. + pushExecutor?.shutdown() + pushExecutor = null super.onDestroy() } diff --git a/android/app/src/main/java/com/ledgrab/android/MainActivity.kt b/android/app/src/main/java/com/ledgrab/android/MainActivity.kt index d6a3ff3..b28831c 100644 --- a/android/app/src/main/java/com/ledgrab/android/MainActivity.kt +++ b/android/app/src/main/java/com/ledgrab/android/MainActivity.kt @@ -34,6 +34,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.MainScope import kotlinx.coroutines.cancel import kotlinx.coroutines.launch +import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.withContext /** @@ -56,6 +57,7 @@ class MainActivity : Activity() { private const val REQUEST_POST_NOTIFICATIONS = 1002 private const val REQUEST_RECORD_AUDIO = 1003 private const val REQUEST_CAMERA = 1004 + private const val REQUEST_BLUETOOTH = 1005 private const val QR_SIZE_PX = 560 private const val NOTIF_PREFS = "ledgrab_notif" private const val KEY_NOTIF_ACCESS_PROMPTED = "notif_access_prompted" @@ -189,7 +191,13 @@ class MainActivity : Activity() { toggleButton.text = getString(R.string.btn_starting) statusText.text = getString(R.string.status_checking_root) uiScope.launch(Dispatchers.IO) { - val rooted = Root.requestGrant() + // runInterruptible so a config change (rotation) during the + // up-to-10s `su` probe cancels the coroutine AND interrupts the + // blocking probe thread — Root.requestGrant honours the interrupt, + // destroys the su child, and rethrows, so we don't leak the + // process + drain thread. Without this, IO-dispatcher cancellation + // would not interrupt the blocking waitFor(). + val rooted = runInterruptible { Root.requestGrant() } withContext(Dispatchers.Main) { toggleButton.isEnabled = true toggleButton.text = originalText @@ -214,6 +222,7 @@ class MainActivity : Activity() { ensureNotificationPermission() ensureNotificationListenerAccess() ensureCameraPermission() + ensureBluetoothPermissions() ContextCompat.startForegroundService(this, CaptureService.createRootIntent(this)) updateUI() } @@ -236,6 +245,7 @@ class MainActivity : Activity() { ensureNotificationListenerAccess() ensureAudioPermission() ensureCameraPermission() + ensureBluetoothPermissions() val intent = CaptureService.createIntent(this, resultCode, resultData) ContextCompat.startForegroundService(this, intent) updateUI() @@ -536,6 +546,30 @@ class MainActivity : Activity() { } } + /** + * Request BLUETOOTH_SCAN + BLUETOOTH_CONNECT (API 31+) so the embedded + * server can discover and drive BLE LED controllers (SP110E / Triones / + * Zengge). On API < 31 these are install-time legacy permissions + * (BLUETOOTH / BLUETOOTH_ADMIN / ACCESS_FINE_LOCATION, maxSdk=30) and + * need no runtime grant — so this is a no-op there. Fire-and-forget, + * like [ensureAudioPermission]: screen capture works without BLE, and + * BleBridge degrades gracefully (empty scan / failed connect) when the + * grant is denied, so we don't block on the result. If first granted + * here, BLE devices become reachable on the next scan/connect. + */ + private fun ensureBluetoothPermissions() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return + val needed = listOf( + Manifest.permission.BLUETOOTH_SCAN, + Manifest.permission.BLUETOOTH_CONNECT, + ).filter { + checkSelfPermission(it) != PackageManager.PERMISSION_GRANTED + } + if (needed.isEmpty()) return + @Suppress("DEPRECATION") + requestPermissions(needed.toTypedArray(), REQUEST_BLUETOOTH) + } + /** Whether the user has granted notification-listener access to this app. */ private fun isNotificationAccessGranted(): Boolean = NotificationManagerCompat.getEnabledListenerPackages(this).contains(packageName) diff --git a/android/app/src/main/java/com/ledgrab/android/Root.kt b/android/app/src/main/java/com/ledgrab/android/Root.kt index 2576bc7..e3ab186 100644 --- a/android/app/src/main/java/com/ledgrab/android/Root.kt +++ b/android/app/src/main/java/com/ledgrab/android/Root.kt @@ -20,6 +20,11 @@ import java.util.concurrent.TimeUnit object Root { private const val TAG = "Root" + // Slice length for the cancellation-aware su probe wait loop. Short + // enough that coroutine cancellation is honoured promptly, long enough + // to avoid busy-spinning while Magisk's grant dialog is up. + private const val POLL_SLICE_MS = 100L + private val SU_PATHS = listOf( "/system/bin/su", "/system/xbin/su", @@ -49,17 +54,19 @@ object Root { return false } + var process: Process? = null val granted = try { // redirectErrorStream merges stderr into stdout so a single // drain thread is enough — avoids the classic pipe-buffer // deadlock where waitFor() blocks because stderr filled up. - val process = ProcessBuilder("su", "-c", "id") + val proc = ProcessBuilder("su", "-c", "id") .redirectErrorStream(true) .start() + process = proc val outputBuilder = StringBuilder() val drain = Thread({ try { - BufferedReader(InputStreamReader(process.inputStream)).use { r -> + BufferedReader(InputStreamReader(proc.inputStream)).use { r -> val buf = CharArray(512) while (true) { val n = r.read(buf) @@ -72,17 +79,35 @@ object Root { } }, "Root-su-drain").apply { isDaemon = true; start() } - val finished = process.waitFor(timeoutSeconds, TimeUnit.SECONDS) + // Cancellation-aware wait: callers run this on a coroutine + // (MainActivity wraps it in runInterruptible), so a config change + // mid-probe cancels the coroutine and interrupts this thread. + // Poll waitFor() in short slices and honour interruption so we + // don't leak the `su` child + its drain thread for up to 10s. + // The catch(InterruptedException) below destroys the process; we + // re-arm the interrupt and rethrow so coroutine cancellation + // propagates cleanly. + val deadlineNanos = System.nanoTime() + TimeUnit.SECONDS.toNanos(timeoutSeconds) + var finished = false + while (System.nanoTime() < deadlineNanos) { + if (proc.waitFor(POLL_SLICE_MS, TimeUnit.MILLISECONDS)) { + finished = true + break + } + // Throws InterruptedException if the thread was interrupted + // by coroutine cancellation — handled below to tear down. + if (Thread.interrupted()) throw InterruptedException("su probe cancelled") + } if (!finished) { - process.destroyForcibly() + proc.destroyForcibly() drain.join(500) Log.w(TAG, "su -c id timed out after ${timeoutSeconds}s") false } else { drain.join(500) val output = synchronized(outputBuilder) { outputBuilder.toString() } - if (process.exitValue() != 0) { - Log.w(TAG, "su -c id exited with ${process.exitValue()} output='${output.trim()}'") + if (proc.exitValue() != 0) { + Log.w(TAG, "su -c id exited with ${proc.exitValue()} output='${output.trim()}'") false } else { val rooted = output.contains("uid=0") @@ -90,8 +115,17 @@ object Root { rooted } } + } catch (e: InterruptedException) { + // Coroutine cancelled mid-probe (e.g. config change). Kill the + // su child so it doesn't outlive the cancelled work, re-arm the + // interrupt flag, and rethrow so the coroutine cancels cleanly. + // Do NOT cache a result — the probe never completed. + runCatching { process?.destroyForcibly() } + Thread.currentThread().interrupt() + throw e } catch (e: Exception) { Log.w(TAG, "su invocation failed: ${e.message}") + runCatching { process?.destroyForcibly() } false } diff --git a/android/app/src/main/java/com/ledgrab/android/RootScreenrecord.kt b/android/app/src/main/java/com/ledgrab/android/RootScreenrecord.kt index 93d9534..8547940 100644 --- a/android/app/src/main/java/com/ledgrab/android/RootScreenrecord.kt +++ b/android/app/src/main/java/com/ledgrab/android/RootScreenrecord.kt @@ -89,14 +89,15 @@ class RootScreenrecord( running = true try { - imageReader = buildImageReader() - decoder = buildDecoder(imageReader!!) - process = spawnScreenrecord() ?: run { + val reader = buildImageReader().also { imageReader = it } + val codec = buildDecoder(reader).also { decoder = it } + val proc = spawnScreenrecord() ?: run { stop() return false } - startInputPump(process!!.inputStream, decoder!!) - startOutputDrain(decoder!!) + process = proc + startInputPump(proc.inputStream, codec) + startOutputDrain(codec) Log.i(TAG, "Root capture pipeline started (${width}x$height @ ${fps}fps)") return true } catch (e: Exception) { @@ -178,6 +179,14 @@ class RootScreenrecord( buffer.get(frameBuffer, row * rowBytes, rowBytes) } } + // CONTRACT: frameBuffer is REUSED across frames (single-threaded + // reader callback — no copy here). Safety depends on the Python + // receiver copying the bytes before this callback returns and + // overwrites the buffer for the next frame. It does: + // PythonBridge.pushRootFrame → root_screenrecord_engine.push_frame + // (server/src/ledgrab/core/capture_engines/root_screenrecord_engine.py) + // does `rgba[:, :, :3].copy()`, so the queued frame owns its + // pixels independently of this buffer. Do NOT remove that copy. bridge.pushRootFrame(frameBuffer, width, height) framesDeliveredCounter.incrementAndGet() } catch (e: Exception) { diff --git a/android/app/src/main/java/com/ledgrab/android/ScreenCapture.kt b/android/app/src/main/java/com/ledgrab/android/ScreenCapture.kt index 2ce7c52..53ed275 100644 --- a/android/app/src/main/java/com/ledgrab/android/ScreenCapture.kt +++ b/android/app/src/main/java/com/ledgrab/android/ScreenCapture.kt @@ -147,6 +147,14 @@ class ScreenCapture( } } + // CONTRACT: frameBuffer is REUSED across frames (single-threaded + // capture handler — no copy here). Safety depends on the Python + // receiver copying the bytes before this callback returns and + // overwrites the buffer for the next frame. It does: + // PythonBridge.pushFrame → mediaprojection_engine.push_frame + // (server/src/ledgrab/core/capture_engines/mediaprojection_engine.py) + // does `rgba[:, :, :3].copy()`, so the queued frame owns its + // pixels independently of this buffer. Do NOT remove that copy. bridge.pushFrame(frameBuffer, captureWidth, captureHeight) // Advance the pacing accumulator. If we fell badly behind diff --git a/plans/activity-log/CONTEXT.md b/plans/activity-log/CONTEXT.md new file mode 100644 index 0000000..efdcb2d --- /dev/null +++ b/plans/activity-log/CONTEXT.md @@ -0,0 +1,73 @@ +# CONTEXT — Activity / Audit Log + +Living scratchpad for the feature. The orchestrator updates this between phases. Tier-2 +context (survives across phases; graduates to CLAUDE.md only if it's a lasting project truth). + +## Config (from approval) + +- **Mode:** Automated · **Execution:** Orchestrator · **Strategy:** Incremental +- **Base/merge target:** `master` · **Branch:** `feature/activity-log` · **Branch point:** `17dd2e0` +- Final merge ALWAYS requires user approval (even in Automated mode). + +## Product decisions (locked) + +- Placement: BOTH a top-level **Activity** tab AND a Dashboard **Recent Activity** widget, + plus a Settings retention panel. +- Scope: all four categories — entity CRUD, auth, device connect/disconnect, capture & system. +- Detail: **action metadata only** (no before/after diffs). +- Durability: **export on demand (CSV/JSON)** + existing whole-DB backup. **No** separate + backup subsystem. +- WebUI work uses the `frontend-design` skill (Phases 5–6). + +## Key codebase facts (verified during planning) + +- `fire_entity_event(entity_type, action, entity_id)` @ `api/dependencies.py:202` — central + hook, called by every entity route synchronously in-request; has `_deps` store access. +- `ProcessorManager.fire_event(dict)` / `subscribe_events()` @ `core/processing/processor_manager.py` + back `/api/v1/events/ws` (`api/routes/output_targets_control.py:206`). `fire_event` does + `put_nowait` (no `call_soon_threadsafe`) — fine for the existing consumer; recorder marshals. +- Frontend realtime: `core/events-ws.ts` re-dispatches `server:`; `_ALLOWED_SERVER_EVENT_TYPES` + (line 39) is parity-checked by `tests/test_events_ws_parity.py`. Must add `activity_logged`. +- Actor: `request.state.auth_label` set in `api/auth.py` (129 authenticated, 83 anonymous). +- Storage: `Database` singleton (`storage/database.py`, single conn, RLock, WAL, + `synchronous=FULL`, `get_setting/set_setting`). `BaseSqliteStore` loads ALL rows to memory → + AVOID for the log. Migrations: `storage/data_migrations.py` (`ALL_MIGRATIONS`, idempotent). +- Backup/restore is **whole-DB** (`Database.backup_to`/`restore_from`, no STORE_MAP allowlist) + → the new `activity_log` table is auto-covered. No `system.py` STORE_MAP edit needed. +- Background-engine pattern: `core/backup/auto_backup.py` (start/stop loop, `_prune`, settings). +- Thread-marshal precedent: `utils/log_broadcaster.py` (`ensure_loop` + `call_soon_threadsafe`). +- Device seams: `device_health_changed` (`core/processing/device_health.py`, on transition) + + `device_discovered`/`device_lost` (`core/devices/discovery_watcher.py`, **zeroconf thread**). +- **No** API-key create/rotate/revoke routes exist (only `GET /system/api-keys`) → those auth + events are DESCOPED. +- Existing **Log Viewer** (`utils/log_broadcaster.py`) = ephemeral debug-log tail; the audit + log is a different, persistent, structured feature. Differentiate; do not duplicate. + +## Frozen contracts (fill as phases complete) + +- ActivityLogEntry fields / dict shape: **frozen** — see phase-1-storage.md Handoff section. 11 fields: `id`, `ts`, `category`, `action`, `severity`, `actor`, `message`, `entity_type`, `entity_id`, `entity_name`, `metadata`. `seq` is DB-only (not on dataclass). +- ActivityLogFilters shape: **frozen** — 8 optional fields: `categories`, `severities`, `actor`, `entity_type`, `entity_id`, `since`, `until`, `message_like`. See phase-1-storage.md Handoff. +- recorder.record(...) signature + actor ContextVar import path: **frozen** — see phase-2-recorder-retention.md Handoff section. Signature: `record(category, action, *, severity="info", actor=None, entity_type=None, entity_id=None, entity_name=None, message, metadata=None, _bypass_enabled=False)`. ContextVar: `from ledgrab.core.activity_log.context import current_actor`. Module accessor: `from ledgrab.core.activity_log.recorder import get_module_recorder`. Event payload: `{"type": "activity_logged", "entry": {11-field dict with ts as ISO string, metadata as dict}}`. DI getters: `get_activity_recorder()`, `get_activity_log_repo()`, `get_activity_log_retention_engine()`. +- API endpoints + query params + page envelope + settings bounds: **frozen** — see phase-4-api.md Handoff section. Endpoints: `GET /api/v1/activity-log` (list, AuthRequired), `GET /api/v1/activity-log/export` (stream CSV/JSON, require_authenticated), `GET|PUT /api/v1/activity-log/settings` (AuthRequired), `DELETE /api/v1/activity-log` (clear, require_authenticated). Page envelope: `entries`, `next_before_seq`, `has_more`, `total`. Settings fields: `enabled` (bool), `max_days` (0–3650), `max_entries` (0–10_000_000). Export: `?format=csv|json`. + +## Failed approaches / rejected designs + +- Buffered async-writer subsystem (asyncio.Queue): REJECTED — unsafe from the zeroconf thread + and adds a shutdown-flush ordering hazard. Using direct synchronous-on-loop writes with + `call_soon_threadsafe` marshaling instead (simpler + correct). +- Using `BaseSqliteStore` for the log: REJECTED — loads all rows into memory. +- Separate activity-log backup subsystem: REJECTED — whole-DB backup already covers the table; + export-on-demand is the portability story. + +## Deferred / open + +- Setup-scaffold first-run noise suppression (batch/suppress flag) — deferred (nice-to-have). + +## Phase progress notes + +Phase 1 landed (2026-06-09): `activity_log.py` (dataclass + enums + filters + codec), `AddActivityLogTableMigration` (`002_add_activity_log`) appended to `ALL_MIGRATIONS`, `ActivityLogRepository` (record/query/count/prune/clear/iter_export), 41 new tests — all green. Full suite 2226 passed, 0 failed. Schema and method signatures frozen in phase-1-storage.md Handoff. Gotcha: `Database.execute` takes a positional tuple — use `?` placeholders (not `:name`), otherwise Python 3.14 will raise `ProgrammingError`. +Phase 2 landed (2026-06-09): `core/activity_log/` package (`context.py`, `recorder.py`, `retention.py`, `__init__.py`); actor ContextVar set in `api/auth.py` (both branches); `ActivityLogRetentionEngine` mirroring AutoBackupEngine; full wiring in `main.py` (repo at module level, recorder+engine in lifespan, `server.shutting_down` first shutdown action, engine stop before db.close); DI getters in `api/dependencies.py`; `activity_logged` added to `_ALLOWED_SERVER_EVENT_TYPES` in `events-ws.ts`; `set_module_recorder` exposes recorder to non-DI sites; 24 new tests — all green. Full suite 2309 passed, 2 skipped, 0 failed. Ruff clean. +Phase 4 landed (2026-06-09): schemas (`api/schemas/activity_log.py`), routes (`api/routes/activity_log.py`: list/export/settings/clear), router registration in `api/__init__.py`, `get_seq_for_id` helper on `ActivityLogRepository`. 49 new tests — all green. Full suite 2486 passed, 2 skipped, 0 failed. Ruff clean. Pagination bug found and fixed (limit+1 probe must drop oldest row when has_more, not tail). +Phase 3 landed (2026-06-09): instrumented all four categories — entity CRUD via `fire_entity_event` choke-point (`dependencies.py`), auth failures + WS session in `auth.py`, device online/offline in `device_health.py`, device discovered/lost in `discovery_watcher.py`, ADB connect/disconnect in `system_settings.py`, capture start/stop (individual + bulk) in `output_targets_control.py`, scene/playlist/automation activate in their respective route/engine files, backup/restore/delete + restart/shutdown/update/calibration/settings in `backup.py`/`update.py`/`calibration.py`; all 11 entity delete handlers pass `entity_name` to `fire_entity_event`; 22 new tests (security: token never in any field, explicitly asserted) — all green. Full suite 2369 passed, 2 skipped, 0 failed. Ruff clean. Complete (category, action) inventory in phase-3-instrumentation.md Handoff section. +Phase 5 landed (2026-06-09): Activity tab frontend — `features/activity-log.ts` (loadActivityLog, filter toolbar with category/severity chips + presets + debounced search + date range + actor/entity-type text fields, keyset-paginated list, expandable detail drawer, live-prepend via `server:activity_logged`, authed CSV/JSON blob export); `core/ui.ts` additions (`formatTimestamp`, `formatRelativeTime`); `core/icon-paths.ts` + `core/icons.ts` additions (scrollText/audit, circleAlert, info, filter, xCircle); `core/tab-registry.ts` registered; `templates/index.html` tab button + panel; `app.ts` + `global.d.ts` wired; `static/css/activity-log.css` (precision-ledger design, category color rail, live-dot pulse, detail drawer, responsive breakpoints); all three locales updated (activity_log.* + time.* relative-time keys + tour.activity_log); `features/tutorials.ts` getting-started tour extended. `tsc --noEmit` clean, `npm run build` passes. Reusable helpers for Phase 6 documented in phase-5-frontend-tab.md Handoff section. +Phase 6 landed (2026-06-09): Dashboard "Recent Activity" widget (live SSE, View-all link, `.dal-*` CSS, loading/empty states) + Settings "Activity Log" panel (enabled toggle, max_days/max_entries, Save toast, authed CSV/JSON export, confirmed Clear, audit-vs-debug cross-links) + 32 i18n keys per locale + README "Activity Log" section. `tsc --noEmit` clean, `npm run build` passes. All six phases complete — feature ready for final review. diff --git a/plans/activity-log/PLAN.md b/plans/activity-log/PLAN.md new file mode 100644 index 0000000..fe17718 --- /dev/null +++ b/plans/activity-log/PLAN.md @@ -0,0 +1,141 @@ +# Feature: Activity / Audit Log + +**Branch:** `feature/activity-log` +**Base branch:** `master` (merge target) +**Branch point:** `17dd2e02bab4d00a93479eb6af1a8c6ddc0c7224` (use for clean review diffs) +**Created:** 2026-06-09 +**Status:** 🟢 All phases complete — awaiting final review + merge +**Strategy:** Incremental +**Mode:** Automated +**Execution:** Orchestrator +**Remote:** origin → https://git.dolgolyov-family.by/alexei.dolgolyov/ledgrab.git + +## Summary + +A persistent, queryable audit log of meaningful LedGrab actions, surfaced in the WebUI. +Captures four categories — entity CRUD, authentication, device connect/disconnect, and +capture & system events — as **action-metadata-only** records (who/what/when + entity +type/name/id + a human-readable message + small structured metadata; **no before/after +diffs**). Surfaced as a dedicated top-level **Activity** tab with smart filtering + live +updates, a compact **Recent Activity** widget on the Dashboard, and a **Settings** panel +for retention. Durability rides on the existing whole-DB `ledgrab.db` backup; portability +is an on-demand CSV/JSON **export** (no separate backup subsystem). + +## Design pillars (the load-bearing decisions) + +1. **Dedicated `activity_log` table + repository — NOT `BaseSqliteStore`.** That base loads + every row into an in-memory cache and uses a generic `id/name/data` blob — wrong for an + append-heavy, unbounded log. We use a purpose-built indexed table with query-on-demand + keyset pagination. +2. **Central choke-point instrumentation.** `fire_entity_event()` (`api/dependencies.py:202`) + is already called by every entity route on create/update/delete and has `_deps` access to + resolve names. The recorder hooks there for all entity CRUD. Non-entity events get explicit + `recorder.record(...)` calls. +3. **Actor via `ContextVar`.** Set inside `verify_api_key` (next to `request.state.auth_label`), + default `"system"`, reset per-request. The recorder reads it without threading actor + through every call. +4. **Direct synchronous write on the event-loop thread** (no separate buffered-writer + subsystem — simpler, and the request already did a `synchronous=FULL` entity write). + Cross-thread callers (zeroconf discovery thread) marshal via `loop.call_soon_threadsafe`, + mirroring `utils/log_broadcaster.py`. The "server shutting down" event is recorded as the + FIRST action in the lifespan shutdown block, before any teardown. +5. **Reuse the existing realtime bus.** A new `activity_logged` event over `/api/v1/events/ws` + (one `events-ws.ts` allowlist entry + `test_events_ws_parity.py` update). No new socket. +6. **Never log secrets.** API-key *tokens* are never stored — only labels/ids. +7. **Differentiate from the existing Log Viewer.** `utils/log_broadcaster.py` is an ephemeral + 500-line debug-log tail. The audit log is persistent, structured, semantic. Cross-link in + the Settings panel; never duplicate. + +## Build & Test Commands + +- **Build (frontend):** `cd server && npm run build` +- **Type-check (TS):** `cd server && npx tsc --noEmit` +- **Test:** `cd server && py -3.13 -m pytest tests/ --no-cov -q` +- **Lint (Python):** `cd server && ruff check src/ tests/ --fix` +- **Events parity test (load-bearing for P2):** included in pytest (`tests/test_events_ws_parity.py`) + +> Scope checks to the files actually edited: backend phases run ruff + pytest; frontend-only +> phases run `tsc --noEmit` + `npm run build` (no pytest/ruff). Phases touching both run both. + +## Phases + +- [x] Phase 1: Storage — model, migration, repository [domain: data] → [subplan](./phase-1-storage.md) +- [x] Phase 2: Recorder, actor context, retention, lifecycle [domain: backend] → [subplan](./phase-2-recorder-retention.md) +- [x] Phase 3: Event instrumentation (4 categories) [domain: backend] → [subplan](./phase-3-instrumentation.md) +- [x] Phase 4: REST API — query/filter/export/settings/clear [domain: backend] → [subplan](./phase-4-api.md) +- [x] Phase 5: Frontend — Activity tab + smart filtering + live updates [domain: frontend] → [subplan](./phase-5-frontend-tab.md) +- [x] Phase 6: Dashboard widget + Settings panel + docs [domain: frontend] → [subplan](./phase-6-dashboard-settings.md) + +## Parallelizable Phase Groups (Orchestrator mode only) + +- Phases 3 and 4 are **parallelizable only after the schema is frozen at end of Phase 2**. + Both depend on the P2 recorder/schema; P4 registers the router in `api/__init__.py`, P3 + edits `dependencies.py`/`auth.py` (different files, shared schema contract). To keep the + Automated run simple and low-risk, they run **sequentially** (3 → 4) unless time pressure + warrants worktree-isolated parallelism. Phases 5 → 6 are sequential (6 reuses P5 formatters + and the feature module). + +## Phase Progress Log + +| Phase | Domain | Status | Review | Build | Committed | +|-------|--------|--------|--------|-------|-----------| +| Phase 1: Storage | data | ✅ Done | ✅ Passed | ✅ Passed | ✅ | +| Phase 2: Recorder/Retention | backend | ✅ Done | ✅ Passed | ✅ Passed | ✅ | +| Phase 3: Instrumentation | backend | ✅ Done | ✅ Passed | ✅ Passed | ✅ | +| Phase 4: REST API | backend | ✅ Done | ✅ Passed | ✅ Passed | ✅ | +| Phase 5: Frontend tab | frontend | ✅ Done | ✅ Passed | ✅ Passed (tsc+build) | ✅ | +| Phase 6: Dashboard/Settings | frontend | ✅ Done | ✅ Passed | ✅ Passed (tsc+build) | ✅ | + +## Outstanding Warnings + +| Phase | Warning | Severity | Status (open / resolved / accepted) | +|-------|---------|----------|-------------------------------------| +| 3 | Log injection via unauth mDNS device name/url into audit message | 🟠 High (security) | resolved — `sanitize_display` helper applied | +| 3 | Origin sanitizer missed spaces/NUL/ANSI | 🟠 High (security) | resolved — `sanitize_display` over netloc | +| 3 | Unauth auth-failure audit-write flood (no write-rate bound) | 🟠 High (security) | resolved — per-IP audit-record throttle (10s, capped) | +| 3 | Malformed-IPv6 Origin → urlparse ValueError into WS handler | 🟡 Warning | resolved — try/except guard | +| 3 | Throttle module-global state caused flaky test contamination | 🟡 Warning | resolved — autouse conftest reset fixture | +| 4 | Export held global DB write-lock across the stream (slow-client DoS) | 🟠 High (security) | resolved — chunked keyset export releases lock per batch | +| 4 | PUT /settings only AuthRequired → anon could disable auditing/prune trail | 🟠 High (security) | resolved — `require_authenticated` on settings PUT | +| 4 | CSV formula-injection missed leading TAB/CR | 🟡 Medium (security) | resolved — added `\t`/`\r` to guard | +| 4 | `total` count full-scans on every list request | 🔵 Low (perf) | accepted — bounded by retention; read-only; optional opt-in deferred | +| 5 | Inverted list ordering broke pagination + live-append | 🔴 Blocker | resolved — pages reversed to newest-first; re-review PASS | +| 5 | Attribute-context XSS (entity_name title + JSON.stringify onclick) | 🟡 Warning (security) | resolved — `_escapeAttr` + data-attr event delegation | +| 5 | Filter toolbar value= attrs not quote-escaped (new code) | 🟡 Warning (security) | resolved — `_escapeAttr` on q/actor/entity_type/since/until | +| 5 | Manual browser smoke test (tab loads, filters, live, export) | 🔵 Note | open — recommend at final review (server restart needed) | +| 6 | clearActivityLog() 401 path unreachable → silent failure | 🟡 Warning | resolved — `handle401:false` surfaces auth-required toast | +| 6 | Recent Activity widget dropped first live event when empty | 🔵 Note | resolved — empty→list transition on first live event | +| 6 | Widget outside dashboard layout-toggle/ordering system | 🔵 Note | accepted — deliberate (always-visible), collapse still works | +| Final | Entity-crosslink map keys mismatched backend entity_type (device/color_strip_source/audio_source) | 🟡 Warning | resolved — `_ENTITY_NAV` keys corrected + scene_playlist added | +| Final | Owner-authored names interpolated raw at some record sites | 🔵 Note (defense-in-depth) | resolved — `sanitize_display` applied uniformly | +| Manual test | Entry descriptions rendered server English (not localized) | 🟡 Warning (acceptance criterion) | resolved — client-side `localizeMessage` from structured fields + `activity_log.msg.*`/`entity_type.*` templates ×3 | +| Manual test | Redundant "Activity" header banner at top of tab | 🔵 Note | resolved — header block removed | +| Manual test | Recent Activity widget missing from Customize Dashboard | 🟡 Warning | resolved — registered as a first-class dashboard section (show/hide/reorder; pre-existing layouts preserved) | +| Manual test | Activity widget live event rebuilt the whole dashboard | 🟡 Warning (perf) | resolved — surgical list update; single listener with teardown | +| Manual test | Relative-time labels static (never tick) | 🟡 Warning | resolved — shared `ensureRelativeTimeTicker` (single 30s interval, visibility-aware) | +| Manual test | Dashboard fully rebuilt/jumped on entity edits (pre-existing `forceFullRender` wholesale innerHTML) | 🟡 Warning (UX) | resolved — section-level reconciliation (`_reconcileDynamicSections`): only changed sections replaced; widget live DOM + perf strip preserved | +| Manual test | Activity list columns slightly misaligned | 🔵 Note | resolved — CSS grid with fixed badge/actor columns | +| Manual test | Reconciler left orphan "no targets" node on empty→populated | 🟡 Warning (regression, caught in review) | resolved — sweep non-section top-level children | + +## Final Review + +- [ ] Comprehensive code review +- [ ] Security review (auth/PII-in-logs/secrets/log-injection — triggered) +- [ ] All Outstanding Warnings resolved or consciously accepted +- [ ] Full build passes (`npm run build` + `tsc --noEmit`) +- [ ] Full test suite passes (`pytest`) +- [ ] Merged to `master` + +## Amendment Log + +_(Filled in if the plan is amended mid-implementation.)_ + +- 2026-06-09: Plan reviewer (pre-implementation) → ⚠️ with 3 Critical Gaps, all resolved + before Phase 1: (G1) descoped non-existent API-key mutation events; (G2) dropped the + buffered-writer subsystem for a direct synchronous-on-loop write with `call_soon_threadsafe` + marshaling for thread-origin events; (G3) record "server shutting down" first in the shutdown + block (no buffer to flush). Concerns folded in: device events via `device_health_changed` + + `device_discovered/_lost`; actor ContextVar in `verify_api_key`; name-on-delete passed + explicitly; settings-audit scoped + self-key excluded; parity allowlist before emit site. + Adopted suggestions: rowid keyset tiebreaker; log the disable action. Deferred: setup-scaffold + noise suppression. diff --git a/plans/activity-log/phase-1-storage.md b/plans/activity-log/phase-1-storage.md new file mode 100644 index 0000000..0cb7518 --- /dev/null +++ b/plans/activity-log/phase-1-storage.md @@ -0,0 +1,177 @@ +# Phase 1: Storage — model, migration, repository + +**Status:** ✅ Done +**Parent plan:** [PLAN.md](./PLAN.md) +**Domain:** data + +## Objective + +Create the persistent foundation for the audit log: an `ActivityLogEntry` dataclass, an +additive idempotent SQLite migration that creates a dedicated indexed `activity_log` table, +and a purpose-built `ActivityLogRepository` (NOT `BaseSqliteStore`) supporting append, +keyset-paginated filtered query, count, time/count-based prune, and streaming export. + +## Tasks + +- [x] Create `server/src/ledgrab/storage/activity_log.py`: + - `ActivityCategory` and `ActivitySeverity` string enums (or `Literal` unions used as + constants). Categories: `auth`, `device`, `entity`, `capture`, `system`. Severities: + `info`, `warning`, `error`. + - `@dataclass ActivityLogEntry` with fields: `id: str` (e.g. `al_`), `ts: datetime` + (UTC, server-assigned), `category: str`, `action: str`, `severity: str`, `actor: str`, + `entity_type: str | None`, `entity_id: str | None`, `entity_name: str | None`, + `message: str`, `metadata: dict` (small JSON; default empty). Provide `to_row()` / + `from_row()` (column tuple/dict ↔ dataclass; `metadata` JSON-encoded; `ts` isoformat). +- [x] Add migration to `server/src/ledgrab/storage/data_migrations.py`: + - New `DataMigration` subclass `AddActivityLogTableMigration` with unique `name` + (next sequential id, e.g. `"NNN_add_activity_log"` — match existing naming) and + `apply(conn)` creating `activity_log` with an INTEGER PRIMARY KEY AUTOINCREMENT `seq` + (monotonic keyset tiebreaker) plus columns: `id TEXT UNIQUE NOT NULL`, `ts TEXT NOT NULL`, + `category TEXT NOT NULL`, `action TEXT NOT NULL`, `severity TEXT NOT NULL`, + `actor TEXT NOT NULL`, `entity_type TEXT`, `entity_id TEXT`, `entity_name TEXT`, + `message TEXT NOT NULL`, `metadata TEXT NOT NULL DEFAULT '{}'`. + - Indexes: `(ts DESC, seq DESC)` (primary keyset/sort), `category`, `severity`, `actor`, + `(entity_type, entity_id)`. Use `CREATE TABLE/INDEX IF NOT EXISTS` for idempotency. + - Append the instance to `ALL_MIGRATIONS` (never reorder existing entries). +- [x] Create `server/src/ledgrab/storage/activity_log_repository.py`: + - `class ActivityLogRepository` taking `db: Database` (NOT subclassing `BaseSqliteStore`). + - `record(entry: ActivityLogEntry) -> None`: single parameterized INSERT via + `db.execute(...)` (auto-commit). The `seq` is DB-assigned. **Caller guarantees this runs + on the event-loop thread** (see Phase 2 — cross-thread marshaling lives in the recorder). + - `query(filters: ActivityLogFilters, *, before_seq: int | None, limit: int) -> list[ActivityLogEntry]`: + keyset pagination `WHERE seq < ? ORDER BY seq DESC LIMIT ?` plus optional filters — + `category IN (...)`, `severity IN (...)`, `actor = ?`, `entity_type = ?`, `entity_id = ?`, + `ts >= ?` / `ts <= ?`, `message LIKE ?` (free-text, `%q%`, escaped). All parameterized. + - `count(filters) -> int`. + - `prune(*, before_ts: datetime | None, max_entries: int | None) -> int`: delete rows older + than `before_ts`, and/or trim to the newest `max_entries` by `seq`. Returns rows deleted. + - `clear() -> int`: delete all rows (used by the API clear endpoint; the clear action is + itself audited by the recorder, not here). Returns rows deleted. + - `iter_export(filters) -> Iterator[ActivityLogEntry]`: cursor-based streaming for export + (does not load all rows into memory). + - Define a small `ActivityLogFilters` dataclass (all-optional fields) in the repository or + `activity_log.py` and reuse it across query/count/prune/export. +- [x] Unit tests in `server/tests/storage/test_activity_log_repository.py`: + - insert + read back round-trip (incl. metadata JSON, UTC ts); + - filter by each dimension (category/severity/actor/entity/date/free-text); + - keyset pagination stability across two pages with same-`ts` rows (seq tiebreaker); + - prune by age and by max_entries; + - clear; count; export iterator yields all matching rows; + - migration idempotency (constructing the repo twice / running migrations twice is safe). + +## Files to Modify/Create + +- `server/src/ledgrab/storage/activity_log.py` — new: dataclass + enums + filters + row codec +- `server/src/ledgrab/storage/data_migrations.py` — modify: add migration + append to `ALL_MIGRATIONS` +- `server/src/ledgrab/storage/activity_log_repository.py` — new: repository +- `server/tests/storage/test_activity_log_repository.py` — new: unit tests + +## Acceptance Criteria + +- `activity_log` table + indexes created idempotently on startup (running migrations twice is a no-op). +- Query is keyset-paginated and index-backed; a 10k-row table never loads fully into memory. +- Pagination is stable when many rows share the same millisecond `ts` (uses `seq` tiebreaker). +- `prune` removes by age AND by max-entry cap; `clear` empties the table; `export` streams. +- All filters use parameterized SQL (no string interpolation of user input). +- New unit tests pass; `ruff check` clean; existing tests still green. + +## Notes + +- Reference patterns: `storage/database.py` (`execute`, `transaction`, `get_setting`), + `storage/data_migrations.py` (`DataMigration`, `MigrationRunner`, `ALL_MIGRATIONS`), + `storage/sync_clock.py` (dataclass `to_dict`/`from_dict` style). +- 🔒 **Migration-safety addendum (data domain):** this migration is purely additive (new + table) — no rename, no field/key/file move, no data movement → no data-loss risk. Still + idempotent (`IF NOT EXISTS`). Rollback = drop the table; no user data is transformed. +- Do NOT wire the repository into `main.py` or `dependencies.py` here — that is Phase 2. +- `Database`'s connection is created with the existing threading model; the repository must + not assume it can be called from arbitrary threads. Thread marshaling is Phase 2's job. + +## Review Checklist + +- [x] All tasks completed +- [x] Code follows project conventions (dataclass codec style, migration naming) +- [x] No unintended side effects (no startup wiring yet) +- [x] Build passes (ruff + pytest) +- [x] Tests pass (new + existing) + +## Handoff to Next Phase + +### ActivityLogEntry — final field list and dict shape + +```python +@dataclass +class ActivityLogEntry: + id: str # "al_" — caller-assigned + ts: datetime # UTC-aware; stored as ISO-8601 string in DB + category: str # ActivityCategory constant + action: str # verb-object label, e.g. "entity.created" + severity: str # ActivitySeverity constant + actor: str # API-key label or "system" + message: str # human-readable description + entity_type: str | None # e.g. "output_target" + entity_id: str | None # stable entity id + entity_name: str | None # name at time of event + metadata: dict # JSON-serialisable; default {} +``` + +`to_row()` returns a flat dict with 11 keys (same names); `metadata` is JSON string, `ts` is isoformat string. `seq` is NOT in `to_row()` — it is DB-assigned. + +### ActivityLogFilters — shape (all fields optional, default None) + +```python +@dataclass +class ActivityLogFilters: + categories: Sequence[str] | None # category IN (...) + severities: Sequence[str] | None # severity IN (...) + actor: str | None # exact match + entity_type: str | None # exact match + entity_id: str | None # exact match + since: datetime | None # ts >= since + until: datetime | None # ts <= until + message_like: str | None # LIKE %value% (escaped) +``` + +### Migration name used + +`"002_add_activity_log"` — appended as position [1] in `ALL_MIGRATIONS`. + +### ActivityLogRepository — exact method signatures + +```python +class ActivityLogRepository: + def __init__(self, db: Database) -> None + def record(self, entry: ActivityLogEntry) -> None + def query( + self, + filters: ActivityLogFilters, + *, + before_seq: int | None = None, + limit: int = 50, + ) -> list[ActivityLogEntry] + def count(self, filters: ActivityLogFilters | None = None) -> int + def prune( + self, + *, + before_ts: datetime | None = None, + max_entries: int | None = None, + ) -> int + def clear(self) -> int + def iter_export( + self, filters: ActivityLogFilters | None = None + ) -> Iterator[ActivityLogEntry] +``` + +### Key behavioural notes for Phase 2/3/4 + +- `record()` expects to be called from the event-loop thread (or with `Database` RLock already held). Phase 2 is responsible for thread marshaling via `loop.call_soon_threadsafe`. +- `query()` returns entries in **ascending chronological order within the page** (reversed internally from DESC fetch for display convenience). The smallest `seq` on a page is `page[0]`'s seq — pass that as `before_seq` for the next page. +- `count(None)` == `count(ActivityLogFilters())` — both count all rows. +- `prune(before_ts=X, max_entries=N)` applies both predicates independently (age prune first, then count cap). +- `iter_export` holds `db._lock` for the entire iteration. Phase 4 should stream the response and consume promptly. +- `ActivityLogCategory` and `ActivityLogSeverity` are plain classes with string class-attributes and an `ALL` tuple — NOT `enum.Enum`. +- Imports for Phase 2/3/4: + ```python + from ledgrab.storage.activity_log import ActivityLogEntry, ActivityLogFilters, ActivityCategory, ActivitySeverity + from ledgrab.storage.activity_log_repository import ActivityLogRepository + ``` diff --git a/plans/activity-log/phase-2-recorder-retention.md b/plans/activity-log/phase-2-recorder-retention.md new file mode 100644 index 0000000..fd33c95 --- /dev/null +++ b/plans/activity-log/phase-2-recorder-retention.md @@ -0,0 +1,196 @@ +# Phase 2: Recorder, actor context, retention, lifecycle + +**Status:** ✅ Done +**Parent plan:** [PLAN.md](./PLAN.md) +**Domain:** backend + +## Objective + +Build the runtime layer over the Phase 1 repository: a thread-safe `ActivityRecorder` facade +that persists an entry AND pushes a live `activity_logged` event; an actor `ContextVar` +populated by the auth layer; a background `ActivityLogRetentionEngine` mirroring +`AutoBackupEngine`; and the `main.py`/`dependencies.py` wiring (init, DI getter, retention +start/stop, shutdown ordering). After this phase the audit log records nothing yet (no call +sites) — that is Phase 3 — but the full machinery is live and unit-tested. + +## Tasks + +- [x] Create `server/src/ledgrab/core/activity_log/__init__.py` and + `server/src/ledgrab/core/activity_log/recorder.py`: + - `ActivityRecorder(repo: ActivityLogRepository, processor_manager, *, loop=None)`. + - `record(category, action, *, severity="info", actor=None, entity_type=None, + entity_id=None, entity_name=None, message, metadata=None) -> None`: + - resolve `actor` from the actor `ContextVar` when not supplied, default `"system"`; + - build an `ActivityLogEntry` (id `al_`, `ts=datetime.now(timezone.utc)`); + - **thread-safe write:** if called on the event loop thread, write inline via + `repo.record(entry)` then fire the live event; if called from another thread (zeroconf + discovery), marshal the whole write+emit onto the loop via + `loop.call_soon_threadsafe(...)`. Capture the loop lazily (mirror + `utils/log_broadcaster.py:ensure_loop`/`call_soon_threadsafe`). Never raise into the + caller — audit recording is best-effort and must not break the audited action; log + failures at `warning`. + - live push: `processor_manager.fire_event({"type": "activity_logged", "entry": entry_as_dict})`. + - Provide a tiny helper to serialize an entry to the same dict shape the API returns + (reuse in Phase 4 / frontend). + - `enabled` flag honored: when retention settings say `enabled=false`, `record()` is a + no-op — EXCEPT the "audit log disabled" event itself, which must be recorded before the + flag takes effect (see retention engine). +- [x] Actor `ContextVar`: + - Add `current_actor: ContextVar[str]` (module-level, e.g. in `core/activity_log/context.py` + or `api/auth.py`). In `verify_api_key` (`api/auth.py`), set it next to the existing + `request.state.auth_label = ...` (both the authenticated label and the `"anonymous"` + branch). Default `"system"` when unset. Ensure no cross-request leakage (set on every + auth evaluation). +- [x] Create `server/src/ledgrab/core/activity_log/retention.py`: + - `ActivityLogRetentionEngine(repo, db, recorder)` mirroring `core/backup/auto_backup.py`: + `_load_settings()`/`_save_settings()` via `db.get_setting("activity_log")` / + `db.set_setting("activity_log", {...})`, `DEFAULT_SETTINGS = {"enabled": True, + "max_days": 90, "max_entries": 20000}`. + `async start()` → spawn `_retention_loop()` (`asyncio.create_task`); loop sleeps a sane + interval (e.g. hourly) then calls `repo.prune(before_ts=now-max_days, max_entries=...)`. + `async stop()` → cancel + await task. `get_settings()` / `async update_settings(...)` + that persist and apply (changing `enabled` is logged via the recorder BEFORE disabling). +- [x] Wiring: + - `main.py`: instantiate `activity_log_repo = ActivityLogRepository(db)` (module level near + other stores); in `lifespan` startup build `activity_recorder` + `activity_log_retention_engine`, + pass to `init_dependencies(...)`, and `await activity_log_retention_engine.start()`. + - In `lifespan` **shutdown**: record a `system` / `server_shutting_down` event via the + recorder as the **first** shutdown action (before engines/db close), then + `await _bounded("activity_log_retention.stop", activity_log_retention_engine.stop(), timeout=0.5)`. + - `api/dependencies.py`: add `activity_recorder` + `activity_log_repo` + + `activity_log_retention_engine` to `_deps`, parameters to `init_dependencies`, and + getters `get_activity_recorder()`, `get_activity_log_repo()`, + `get_activity_log_retention_engine()`. +- [x] Realtime allowlist (order matters — do allowlist FIRST so the parity test stays green): + - Add `'activity_logged'` to `_ALLOWED_SERVER_EVENT_TYPES` in + `server/src/ledgrab/static/js/core/events-ws.ts` (+ a one-line comment naming the source). + - Confirm `tests/test_events_ws_parity.py` passes with the new emit type. +- [x] Unit tests `server/tests/core/test_activity_recorder.py` + + `test_activity_log_retention.py`: + - recorder persists an entry AND calls `fire_event` with `type=="activity_logged"`; + - actor resolves from ContextVar; defaults to `"system"`; failure in repo doesn't raise; + - cross-thread `record()` (call from a `threading.Thread`) routes through the loop and persists; + - retention prunes per settings; settings round-trip via db; disabling logs the disable event. + +## Files to Modify/Create + +- `server/src/ledgrab/core/activity_log/__init__.py` — new +- `server/src/ledgrab/core/activity_log/recorder.py` — new +- `server/src/ledgrab/core/activity_log/context.py` — new (actor ContextVar) *(or place in auth.py)* +- `server/src/ledgrab/core/activity_log/retention.py` — new +- `server/src/ledgrab/api/auth.py` — modify: set actor ContextVar in `verify_api_key` +- `server/src/ledgrab/main.py` — modify: instantiate, wire lifespan start/shutdown +- `server/src/ledgrab/api/dependencies.py` — modify: `_deps`, `init_dependencies`, getters +- `server/src/ledgrab/static/js/core/events-ws.ts` — modify: allowlist `activity_logged` +- `server/tests/core/test_activity_recorder.py` — new +- `server/tests/core/test_activity_log_retention.py` — new + +## Acceptance Criteria + +- Recorder persists + fires `activity_logged`; never raises into callers; thread-safe from + non-loop threads. +- Actor ContextVar populated by auth; default `"system"`; no cross-request leakage. +- Retention engine starts/stops cleanly in lifespan; prunes by age + count; settings persist. +- `server_shutting_down` is recorded before teardown; no lost-on-graceful-shutdown entries. +- `test_events_ws_parity.py` green (allowlist updated). Existing tests still green; `ruff` clean. + +## Notes + +- Reference: `core/backup/auto_backup.py` (engine shape, settings persistence, `_bounded` + shutdown in `main.py`), `utils/log_broadcaster.py` (`ensure_loop`, `call_soon_threadsafe` + thread marshaling), `core/processing/processor_manager.py:247` (`fire_event`). +- **Do not add any instrumentation call sites in this phase** — only the machinery. Phase 3 + adds the `record(...)` calls. (Intermediate commit emits nothing; that is fine and green.) +- Freeze the `ActivityLogEntry` dict shape here — Phase 4 (API response) and Phase 5 + (frontend `entry`) consume it. + +## Review Checklist + +- [x] All tasks completed +- [x] Code follows project conventions (engine/DI patterns) +- [x] No unintended side effects (no call sites yet; lifespan order correct) +- [x] Build passes (ruff + pytest, incl. parity test) +- [x] Tests pass (new + existing) + +## Handoff to Next Phase + +### recorder.record(...) — final signature + +```python +recorder.record( + category: str, # ActivityCategory constant + action: str, # verb-object label + *, + severity: str = "info", # ActivitySeverity constant + actor: str | None = None, # resolved from current_actor ContextVar when None + entity_type: str | None = None, + entity_id: str | None = None, + entity_name: str | None = None, + message: str, + metadata: dict | None = None, + _bypass_enabled: bool = False, # internal: used by retention engine only +) -> None +``` + +### Actor ContextVar import path + +```python +from ledgrab.core.activity_log.context import current_actor +``` + +### Module accessor (for non-DI sites) + +```python +from ledgrab.core.activity_log.recorder import get_module_recorder, set_module_recorder +recorder = get_module_recorder() # returns ActivityRecorder | None +``` + +### entry_to_dict helper (for API response serialisation) + +```python +from ledgrab.core.activity_log.recorder import entry_to_dict +d = entry_to_dict(entry) # returns dict with 11 keys +``` + +### Frozen `activity_logged` event payload shape + +```python +{ + "type": "activity_logged", + "entry": { + "id": str, # "al_<8-hex>" + "ts": str, # ISO-8601 UTC string + "category": str, + "action": str, + "severity": str, + "actor": str, + "entity_type": str | None, + "entity_id": str | None, + "entity_name": str | None, + "message": str, + "metadata": dict, # real dict, not JSON string + } +} +``` + +### DI getter names (in `api/dependencies.py`) + +```python +from ledgrab.api.dependencies import ( + get_activity_recorder, + get_activity_log_repo, + get_activity_log_retention_engine, +) +``` + +### Notes for Phase 3 + +- Phase 3 instruments `fire_entity_event` in `api/dependencies.py` by calling + `get_module_recorder()` there (not via FastAPI DI — it's a plain function). +- The actor ContextVar is already set by `verify_api_key` before any route + handler runs, so entity events carry the correct actor automatically. +- `recorder.record(...)` never raises; Phase 3 call sites need no try/except. + +Phase 2 landed (2026-06-09): ActivityRecorder, actor ContextVar, ActivityLogRetentionEngine, +all wiring in main.py/dependencies.py/auth.py, activity_logged allowlist in events-ws.ts, +24 new tests — all green. Full suite 2309 passed, 2 skipped, 0 failed. Ruff clean. diff --git a/plans/activity-log/phase-3-instrumentation.md b/plans/activity-log/phase-3-instrumentation.md new file mode 100644 index 0000000..efc2195 --- /dev/null +++ b/plans/activity-log/phase-3-instrumentation.md @@ -0,0 +1,172 @@ +# Phase 3: Event instrumentation (4 categories) + +**Status:** ✅ Done +**Parent plan:** [PLAN.md](./PLAN.md) +**Domain:** backend · 🔒 security-sensitive (security reviewer triggers) + +## Objective + +Emit audit records at the real call sites for all four categories, using the Phase 2 recorder. +Maximize coverage via the central `fire_entity_event` choke point; add explicit +`recorder.record(...)` calls for non-entity events. Never log secrets. + +## Tasks + +### Entity CRUD (via the choke point) +- [x] In `api/dependencies.py`, extend `fire_entity_event` to ALSO record an audit entry: + - Signature gains an optional `entity_name: str | None = None`. + - For `created`/`updated`: if `entity_name` not supplied, best-effort resolve from the + matching store in `_deps` keyed by `entity_type` (entity still present). For `deleted`: + **do not** resolve post-hoc — rely on the explicit `entity_name` passed by the handler + (deletes are the most important; a name-less delete entry is unacceptable). + - Map `action` → severity (`info`), category `entity`. Build a human message + (e.g. `"Target 'Desk' updated"`). Read actor from the ContextVar. + - Recording is best-effort (never break the entity operation). +- [x] Update entity **delete** handlers to pass `entity_name` into `fire_entity_event` + (the entity object is already loaded for the 404 check). Cover the representative/most-used + entities at minimum: output targets, sync clocks, devices, picture/audio/color-strip + sources, automations, scene presets/playlists, templates, gradients. (Create/update can rely + on hook resolution but pass the name where trivially available.) + +### Authentication (DESCOPED: no key create/rotate/revoke — those routes don't exist) +- [x] In `api/auth.py`, record: + - auth **failures**: missing/invalid Bearer token (HTTP), rejected LAN-without-keys, rejected + WS origin (4403), WS auth handshake failure (4401). Category `auth`, severity `warning`. + Include the caller IP/label and the reason in `metadata` — **never** the attempted token. + - WS **session establishment** (successful `accept_and_authenticate_ws`): category `auth`, + severity `info`, actor = authenticated label. + - (Do NOT record per-request HTTP auth *success* — too frequent.) + +### Device connect/disconnect (use existing discrete seams) +- [x] Hook `device_health_changed` (`core/processing/device_health.py`, fired only on + `online != prev_online`) → record online/offline transition. Category `device`, + severity `info` (online) / `warning` (offline). +- [x] Hook `device_discovered` / `device_lost` (`core/devices/discovery_watcher.py`, **runs on + the zeroconf thread** → recorder must marshal to the loop, which Phase 2 handles). Category + `device`. +- [x] ADB connect/disconnect (`api/routes/system_settings.py:adb_connect/adb_disconnect`). + +### Capture & system events (explicit record calls) +- [x] Target processing start/stop + bulk (`api/routes/output_targets_control.py`). +- [x] Scene activation (`scene_presets.py:activate_scene_preset`), playlist start/stop + (`scene_playlists.py`), automation activate/deactivate (`automation_engine.py`). +- [x] System: backup create/restore/delete (`backup.py`), update apply/dismiss (`update.py`), + restart/shutdown (`backup.py`), calibration start/stop/cancel (`calibration.py`). +- [x] Settings changes: scope to high-value settings only (auto-backup, update, shutdown + action). **Exclude the activity-log's own `"activity_log"` settings key** to avoid + self-referential churn. + +### Tests +- [x] `server/tests/test_activity_instrumentation.py` (or per-area): + - representative entity create/update/delete produces a record with correct category/actor/ + name (incl. a delete carrying its name); + - an auth failure produces a `warning` record and the token never appears in any field; + - a device health transition and a discovery event produce records; + - a capture start and a backup/restore produce records. + +## Files to Modify/Create + +- `server/src/ledgrab/api/dependencies.py` — modify: `fire_entity_event` records + `entity_name` +- entity **delete** route handlers under `api/routes/` — modify: pass `entity_name` +- `server/src/ledgrab/api/auth.py` — modify: auth-failure + WS-session records +- `server/src/ledgrab/core/processing/device_health.py` — modify: online/offline record +- `server/src/ledgrab/core/devices/discovery_watcher.py` — modify: discovered/lost record +- `server/src/ledgrab/api/routes/system_settings.py` — modify: ADB + settings records +- `server/src/ledgrab/api/routes/output_targets_control.py` — modify: start/stop records +- `server/src/ledgrab/api/routes/{scene_presets,scene_playlists,backup,update,calibration}.py` — modify +- `server/src/ledgrab/core/automations/automation_engine.py` — modify: activate/deactivate records +- `server/tests/test_activity_instrumentation.py` — new + +## Acceptance Criteria + +- All four categories emit records at the named sites; entity deletes carry the entity name. +- API-key tokens / secrets never appear in any audit field (test-enforced). +- Recording never breaks the audited action (best-effort; failures swallowed + logged). +- Actor is the authenticated label for request-originated events, `"system"` for engine/thread + events. New + existing tests green; `ruff` clean. + +## Notes + +- Get the recorder via the Phase 2 DI getter; for engine/thread sites that lack DI, use the + module singleton/accessor Phase 2 exposes. +- Keep messages human-readable and localized-agnostic (English source strings; the frontend + renders structured fields, not server message translation — message is a fallback/summary). +- This is the security-sensitive phase — the security reviewer runs here AND at final review. + +## Review Checklist + +- [x] All tasks completed +- [x] Code follows project conventions +- [x] No unintended side effects (audited actions still succeed on recorder failure) +- [x] No secrets logged (token never recorded) — explicitly verified +- [x] Build passes (ruff + pytest) +- [x] Tests pass (new + existing) + +## Handoff to Next Phase + +Phase 3 is complete. The following (category, action) pairs are now emitted, along with their +metadata keys, for Phase 4 to expose via query/filter and for Phase 5 quick-filter presets. + +### `entity` category + +| Action | Severity | Metadata keys | Notes | +|--------|----------|---------------|-------| +| `entity.created` | info | — | All entity types via `fire_entity_event` choke-point | +| `entity.updated` | info | — | All entity types; name resolved from store when not passed | +| `entity.deleted` | info | — | Name passed explicitly by delete handler before deletion | + +### `auth` category + +| Action | Severity | Metadata keys | Notes | +|--------|----------|---------------|-------| +| `auth.rejected` | warning | `reason` (str), `client` (str/IP) | Missing Bearer, invalid Bearer, LAN-no-keys, WS origin, WS auth timeout, invalid WS token | +| `auth.ws_connected` | info | `client` (str/IP) | Successful WS session established | + +### `device` category + +| Action | Severity | Metadata keys | Notes | +|--------|----------|---------------|-------| +| `device.online` | info | `latency_ms` (float) | Health monitor, transition only | +| `device.offline` | warning | `latency_ms` (float) | Health monitor, transition only | +| `device.discovered` | info | `url` (str), `device_type` (str) | Zeroconf discovery thread; recorder marshals to loop | +| `device.lost` | warning | `url` (str), `device_type` (str) | Zeroconf discovery thread | +| `device.adb_connected` | info | `address` (str) | ADB route success | +| `device.adb_disconnected` | info | `address` (str) | ADB route success | + +### `capture` category + +| Action | Severity | Metadata keys | Notes | +|--------|----------|---------------|-------| +| `capture.started` | info | — | Per target (individual + bulk) | +| `capture.stopped` | info | — | Per target (individual + bulk) | +| `scene.activated` | info | — | `scene_presets.py:activate_scene_preset` | +| `playlist.started` | info | — | `scene_playlists.py:start_scene_playlist` | +| `playlist.stopped` | info | — | `scene_playlists.py:stop_scene_playlist` | +| `automation.activated` | info | — | `automation_engine.py:_activate_automation`; actor="system" | +| `automation.deactivated` | info | — | `automation_engine.py:_deactivate_automation`; actor="system" | + +### `system` category + +| Action | Severity | Metadata keys | Notes | +|--------|----------|---------------|-------| +| `backup.created` | info | `filename` (str) | `backup.py:backup_config` | +| `backup.restored` | info | — | `backup.py:restore_config` | +| `backup.deleted` | info | `filename` (str) | `backup.py:delete_saved_backup` | +| `server.restarting` | info | — | `backup.py:restart_server` | +| `server.shutdown_requested` | info | — | `backup.py:shutdown_server` | +| `update.dismissed` | info | `version` (str) | `update.py:dismiss_update` | +| `update.applied` | info | `version` (str) | `update.py:apply_update` | +| `settings.changed` | info | `setting_key` (str) + setting-specific keys | `setting_key` values: `"auto_backup"`, `"update"`, `"shutdown_action"`. Activity-log own key excluded. | +| `calibration.started` | info | — | `calibration.py`; entity_type="device", entity_id=device_id | +| `calibration.stopped` | info | — | `calibration.py` | +| `calibration.cancelled` | info | — | `calibration.py` | + +### Implementation notes for Phase 4 + +- The `metadata` field is a JSON `TEXT` column. All keys above are scalars (str, float). +- Phase 4 filter `metadata_key` / `metadata_value` lookup, if added, can target `setting_key` + for settings-change filtering. +- `entity_type` is populated for entity CRUD and `calibration.started`. For auth/system/capture + events `entity_type` may be None. +- `entity_name` is always populated for `entity.deleted`; populated for CRUD create/update + when resolved; populated for most capture/system events where a name is meaningful. diff --git a/plans/activity-log/phase-4-api.md b/plans/activity-log/phase-4-api.md new file mode 100644 index 0000000..d0d53eb --- /dev/null +++ b/plans/activity-log/phase-4-api.md @@ -0,0 +1,163 @@ +# Phase 4: REST API — query / filter / export / settings / clear + +**Status:** ✅ Done +**Parent plan:** [PLAN.md](./PLAN.md) +**Domain:** backend + +## Objective + +Expose the audit log over the REST API: a filtered, keyset-paginated list endpoint; a +streaming CSV/JSON export honoring the same filters; retention settings get/update; and a +destructive clear. Apply the project's auth posture (stricter auth on export + clear). + +## Tasks + +- [x] `server/src/ledgrab/api/schemas/activity_log.py` (Pydantic): + - `ActivityLogEntryResponse` (matches the frozen Phase 2 entry dict shape). + - `ActivityLogPageResponse` { `entries: list[...]`, `next_before_seq: int | None`, + `total: int` (optional/over filters), `has_more: bool` }. + - `ActivityLogSettingsResponse` / `UpdateActivityLogSettingsRequest` + (`enabled`, `max_days`, `max_entries`) with validation bounds. +- [x] `server/src/ledgrab/api/routes/activity_log.py` — `APIRouter(prefix="/api/v1/activity-log")`: + - `GET ""` — list. Query params: `categories`, `severities`, `actor`, `entity_type`, + `entity_id`, `since`/`until` (ISO), `q` (free-text), `before_seq` (cursor), `limit` + (default 50, capped e.g. 200). `AuthRequired`. Maps params → `ActivityLogFilters`, + calls `repo.query(...)`, returns page envelope. + - `GET "/export"` — streaming export. Same filters; `format=csv|json`. Uses + `StreamingResponse` over `repo.iter_export(...)`. **`require_authenticated()`** (may + contain IPs/labels). Sets `Content-Disposition` with a timestamped filename. + - `GET "/settings"` / `PUT "/settings"` — retention settings via the retention engine. + `AuthRequired`; updates apply immediately. + - `DELETE ""` — clear all entries. **`require_authenticated()`**. The clear is itself + audited (recorder records a `system`/`activity_log_cleared` entry AFTER the wipe, so the + log shows who cleared it and when). + - Register the router in `server/src/ledgrab/api/__init__.py` (aggregator). +- [x] API tests `server/tests/api/routes/test_activity_log_api.py`: + - list returns entries; each filter narrows results; `before_seq` cursor paginates without + overlap/gaps; `limit` cap enforced; + - export CSV and JSON both stream and honor filters; export requires authentication + (401 for loopback-anonymous when keys configured); + - settings get/update round-trip + validation rejects out-of-range; + - clear empties the log, requires auth, and leaves exactly one post-clear audit entry. + +## Files to Modify/Create + +- `server/src/ledgrab/api/schemas/activity_log.py` — new +- `server/src/ledgrab/api/routes/activity_log.py` — new +- `server/src/ledgrab/api/__init__.py` — modify: register router +- `server/tests/api/routes/test_activity_log_api.py` — new + +## Acceptance Criteria + +- List is filterable on every dimension and keyset-paginated (stable, no dupes/gaps). +- Export streams CSV + JSON, honors filters, and requires authentication. +- Settings get/update works and validates bounds; changes take effect immediately. +- Clear requires authentication and is itself audited. +- New + existing tests green; `ruff` clean. + +## Notes + +- Auth helpers: `AuthRequired` dependency for normal endpoints; `require_authenticated()` for + export + clear (pattern: backup download / secret reveal). See `api/auth.py` + `server/CLAUDE.md`. +- Follow the existing route/schema conventions (one schema file per entity, router registered + in `api/__init__.py`). Reference `api/routes/backup.py` for settings-style GET/PUT + a + `StreamingResponse`/download pattern. +- Reuse the entry→dict serializer from Phase 2 to keep the response shape single-sourced. +- Backup/restore: no `STORE_MAP` change needed — backup is whole-DB; the table is auto-covered. + +## Review Checklist + +- [x] All tasks completed +- [x] Code follows project conventions (router registration, schema-per-entity, auth posture) +- [x] No unintended side effects +- [x] Build passes (ruff + pytest) +- [x] Tests pass (new + existing) + +## Handoff to Next Phase + +### Endpoint paths + +| Method | Path | Auth | +|--------|------|------| +| GET | `/api/v1/activity-log` | AuthRequired (anonymous allowed) | +| GET | `/api/v1/activity-log/export` | require_authenticated (no anonymous) | +| GET | `/api/v1/activity-log/settings` | AuthRequired | +| PUT | `/api/v1/activity-log/settings` | AuthRequired | +| DELETE | `/api/v1/activity-log` | require_authenticated (no anonymous) | + +### List query parameters (GET /api/v1/activity-log) + +| Param | Type | Default | Notes | +|-------|------|---------|-------| +| `categories` | `list[str]` | — | Repeatable. Values: auth, device, entity, capture, system | +| `severities` | `list[str]` | — | Repeatable. Values: info, warning, error | +| `actor` | `str` | — | Exact match | +| `entity_type` | `str` | — | Exact match | +| `entity_id` | `str` | — | Exact match | +| `since` | `datetime` (ISO-8601) | — | Inclusive lower bound on ts | +| `until` | `datetime` (ISO-8601) | — | Inclusive upper bound on ts | +| `q` | `str` | — | Substring match on message (LIKE %q%) | +| `before_seq` | `int` | — | Keyset cursor from previous page's `next_before_seq` | +| `limit` | `int` | 50 | Max entries per page. ge=1, le=200 | + +Export endpoint (`GET /api/v1/activity-log/export`) accepts the same filter params plus `format=csv|json`. + +### Page envelope fields (ActivityLogPageResponse) + +```json +{ + "entries": [...], // list[ActivityLogEntryResponse] + "next_before_seq": 42, // int | null — pass as before_seq for next page + "has_more": true, // bool + "total": 1337 // int — total matching all filters (all pages) +} +``` + +### Entry dict shape (ActivityLogEntryResponse) + +11 fields — identical to `entry_to_dict()` output: +```json +{ + "id": "al_abcd1234", + "ts": "2026-06-09T12:34:56.789+00:00", + "category": "entity", + "action": "entity.created", + "severity": "info", + "actor": "my-api-key", + "entity_type": "output_target", + "entity_id": "pt_abc", + "entity_name": "Desk", + "message": "Output target 'Desk' created", + "metadata": {} +} +``` + +### Settings field bounds (UpdateActivityLogSettingsRequest) + +| Field | Type | ge | le | Notes | +|-------|------|----|----|-------| +| `enabled` | `bool` | — | — | Enable/disable recording | +| `max_days` | `int` | 0 | 3650 | 0 = no age-based pruning | +| `max_entries` | `int` | 0 | 10_000_000 | 0 = no count-based pruning | + +### Export format param + +`?format=csv` (default) → `text/csv; charset=utf-8` +`?format=json` → `application/json` (streamed JSON array) + +### Pagination algorithm + +Keyset cursor (`before_seq`) works as follows: +- Omit `before_seq` (or pass `null`) to get the FIRST (newest) page. +- Each page response includes `next_before_seq` (the seq of the oldest entry on the page). +- Pass `next_before_seq` as `before_seq` in the next request to get the following (older) page. +- `has_more=false` means there are no more pages; `next_before_seq` is `null`. +- `total` is constant across pages for the same filter set. + +### New method added to ActivityLogRepository (additive, not breaking) + +`get_seq_for_id(entry_id: str) -> int | None` — indexed point-lookup of seq by entry id. +Used internally by the list endpoint to build the keyset cursor. + +Phase 4 landed (2026-06-09): schemas, route (list/export/settings/clear), router registration, +49 new tests — all green. Full suite 2486 passed, 2 skipped, 0 failed. Ruff clean. diff --git a/plans/activity-log/phase-5-frontend-tab.md b/plans/activity-log/phase-5-frontend-tab.md new file mode 100644 index 0000000..2d88c42 --- /dev/null +++ b/plans/activity-log/phase-5-frontend-tab.md @@ -0,0 +1,137 @@ +# Phase 5: Frontend — Activity tab + smart filtering + live updates + +**Status:** ✅ Done +**Parent plan:** [PLAN.md](./PLAN.md) +**Domain:** frontend · uses the `frontend-design` skill + +## Objective + +Build the dedicated top-level **Activity** tab: a read-only, smart-filterable, +keyset-paginated log viewer with an entry detail view, live-append of new events, and export. +This is a viewer (Dashboard-style), NOT a CRUD card section. + +## Tasks + +- [x] `core/ui.ts`: add `formatTimestamp(isoOrMs)` (Today/Yesterday/Date · HH:MM, i18n-aware) + and `formatRelativeTime(isoOrMs)` ("2m ago"), with `tabular-nums` styling guidance for the + list. Reuse existing `time.*` i18n key conventions. +- [x] `features/activity-log.ts`: + - `export async function loadActivityLog()` — fetch first page from + `GET /activity-log` (via `fetchWithAuth`), render the toolbar + list into the panel. + - **Smart filter toolbar:** category (multi, chips), severity (chips), actor + (text input), entity type, date range, free-text search (debounced). Quick presets: + Today / Errors / Auth / Devices. Filters drive server-side query params (no client-side + filtering of a partial page). Re-query on change; reset cursor. + - **List:** one row per entry — severity icon, category badge, relative time (title=absolute), + actor, message, entity crosslink (use `navigateToCard(...)` when the referenced entity is + resolvable). Keyset "load more" (or infinite scroll) using `next_before_seq`. + - **Detail:** expandable row / drawer showing full metadata JSON, exact timestamp, ids. + - **Live append:** `document.addEventListener('server:activity_logged', e => …)` — prepend + the new entry if it passes the active filters; show a subtle "new" affordance. (Depends on + the Phase 2 allowlist entry — already shipped.) + - **Export button:** triggers `GET /activity-log/export?format=…` with current filters via an + authed blob download (use `fetchWithAuth` → blob URL, per frontend.md auth rules). + - Empty / loading / error states; re-render on `languageChanged`. +- [x] Tab wiring: + - `core/tab-registry.ts`: add `activity_log: { loadFnName: 'loadActivityLog', autoRefresh: false }`. + - `templates/index.html`: sidebar tab button (`data-tab`, `switchTab('activity_log')`, + history/clock SVG icon, `data-i18n`) + `
`. + - `app.ts`: import + `Object.assign(window, { loadActivityLog, … })`; `global.d.ts` decls. +- [x] Icons: add a history/audit icon to `core/icon-paths.ts` + `core/icons.ts`; severity icons + (info/warning/error) reuse existing constants where possible. +- [x] i18n: add `activity_log.*` keys to `static/locales/{en,ru,zh}.json` (title, filter labels, + category/severity names, column labels, presets, empty/error, export, "N entries"). +- [x] Tutorials: add an Activity-tab step to the getting-started tour in + `features/tutorials.ts` + `tour.*` keys in all 3 locales. + +## Files to Modify/Create + +- `server/src/ledgrab/static/js/core/ui.ts` — modified: timestamp/relative-time formatters +- `server/src/ledgrab/static/js/features/activity-log.ts` — NEW: the viewer +- `server/src/ledgrab/static/js/core/tab-registry.ts` — modified: register tab +- `server/src/ledgrab/templates/index.html` — modified: tab button + panel +- `server/src/ledgrab/static/js/app.ts` — modified: import + window globals +- `server/src/ledgrab/static/js/global.d.ts` — modified: window decls +- `server/src/ledgrab/static/js/core/icon-paths.ts` — modified: icons (scrollText, circleAlert, info, filter, xCircle) +- `server/src/ledgrab/static/js/core/icons.ts` — modified: ICON_ACTIVITY_LOG, ICON_SEVERITY_*, ICON_FILTER, ICON_X_CIRCLE +- `server/src/ledgrab/static/locales/{en,ru,zh}.json` — modified: activity_log.* + time.* + tour.activity_log +- `server/src/ledgrab/static/js/features/tutorials.ts` — modified: gettingStartedSteps +- `server/src/ledgrab/static/css/activity-log.css` — NEW: list + toolbar styling +- `server/src/ledgrab/static/css/all.css` — modified: import activity-log.css + +## Acceptance Criteria + +- [x] New **Activity** tab loads, lists entries, and paginates via keyset "load more". +- [x] Filters hit server-side query params; quick presets work; free-text is debounced. +- [x] New events append live via `server:activity_logged` and respect active filters. +- [x] Export downloads CSV/JSON with auth, honoring current filters. +- [x] Fully localized (en/ru/zh); empty/loading/error states; re-renders on language change. +- [x] No plain ``; SVG icons only. + +## Notes + +- **Use the `frontend-design` skill** for the widget + settings panel layout. +- Reuse Phase 5's render helper and i18n namespace — no duplicated row markup or keys. +- Settings UI model: `features/settings.ts switchSettingsTab` + `modals/settings.html` rail. +- Frontend-only phase → run `tsc --noEmit` + `npm run build`; do NOT run pytest/ruff (docs + + TS only). +- Backup/restore cross-ref: no `STORE_MAP` edit needed (whole-DB backup covers the table) — + confirm nothing else (graph editor) needs syncing for this viewer (verified: not needed). + +## Review Checklist + +- [x] All tasks completed +- [x] Code follows frontend conventions (i18n ×3, icons, selectors, CSS vars, settings pattern) +- [x] No unintended side effects +- [x] Build passes (`tsc --noEmit` + `npm run build`) +- [ ] Manual smoke: widget live-updates + links; settings save/clear/export; docs updated (recommend at final review — requires server restart) + +## Handoff to Next Phase + +This is the **final implementation phase**. All six phases are complete. Notes for the final reviewer: + +**What was implemented in Phase 6:** + +- `features/dashboard.ts`: `_loadRecentActivityWidget()`, `_renderRecentActivityList()`, `_startRecentActivityLive()` — SSE live-update listener (`server:activity_logged`) with cap-to-5 prepend logic. Widget appended after `getOrderedSections()` loop (outside the layout toggle system — always visible). Non-blocking: `.catch()` on the async load call. +- `features/settings.ts`: `loadActivityLogSettings()`, `saveActivityLogSettings()`, `activityLogSettingsExport(format)`, `clearActivityLog()` — all exported and exposed on `window` via `app.ts` + `global.d.ts`. +- `templates/modals/settings.html`: Activity Log rail entry (System group, cyan channel) + full panel (`id="settings-panel-activity_log"`) with enabled toggle, `max_days`/`max_entries` inputs, Save, CSV/JSON export, Clear (danger zone). Audit-vs-debug distinction note with cross-links in both directions (`closeSettingsModal(); openLogOverlay()` and `closeSettingsModal(); switchTab('activity_log')`). +- `static/css/activity-log.css`: `.dal-*` dashboard widget styles + `.ds-info-note` / `.ds-inline-link` settings panel utilities appended (no new CSS file). +- `static/locales/{en,ru,zh}.json`: 32 new keys each under `dashboard.section.recent_activity`, `dashboard.recent_activity.*`, `settings.tab.activity_log`, `settings.activity_log.*`. +- `README.md`: "### Activity Log" section documenting tab, retention settings, export, and audit-vs-debug distinction. + +**Reused Phase 5 helpers (no duplication):** + +- `fetchRecentEntries(limit)` and `renderCompactEntry(entry)` — new public exports added to `activity-log.ts` in Phase 6 (not duplicated in dashboard.ts). +- All `.al-*` CSS classes from Phase 5 are reused in the compact rows inside the widget. + +**Build verification:** `tsc --noEmit` clean, `npm run build` passed (2.8 MB bundle, 258 ms) at time of implementation. + +**Remaining manual smoke test (requires server restart):** + +- Dashboard widget loads recent entries, prepends live on new activity, "View all →" switches to Activity tab. +- Settings panel reads/writes retention, Save shows toast, Clear prompts confirm then deletes, Export downloads authed blob. +- Cross-links: note in Settings opens Log Viewer overlay; note in Log Viewer links back to Activity tab. + +**Outstanding open note from PLAN.md:** The manual browser smoke test across the whole feature (P5 note) remains open — it requires a server restart to exercise the live API endpoints. Recommend as first step in the final review session. diff --git a/server/src/ledgrab/api/__init__.py b/server/src/ledgrab/api/__init__.py index b8c166e..5fb778c 100644 --- a/server/src/ledgrab/api/__init__.py +++ b/server/src/ledgrab/api/__init__.py @@ -38,6 +38,7 @@ from .routes.snapshot import router as snapshot_router from .routes.graph import router as graph_router from .routes.calibration import router as calibration_router from .routes.setup import router as setup_router +from .routes.activity_log import router as activity_log_router router = APIRouter() router.include_router(system_router) @@ -76,5 +77,6 @@ router.include_router(snapshot_router) router.include_router(graph_router) router.include_router(calibration_router) router.include_router(setup_router) +router.include_router(activity_log_router) __all__ = ["router"] diff --git a/server/src/ledgrab/api/auth.py b/server/src/ledgrab/api/auth.py index 6f5e68c..f0ebf55 100644 --- a/server/src/ledgrab/api/auth.py +++ b/server/src/ledgrab/api/auth.py @@ -3,18 +3,111 @@ import asyncio import json import secrets +import time from typing import Annotated +from urllib.parse import urlparse from fastapi import Depends, HTTPException, Request, Security, status from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from starlette.websockets import WebSocket, WebSocketDisconnect from ledgrab.config import get_config +from ledgrab.core.activity_log.context import current_actor +from ledgrab.core.activity_log.sanitize import sanitize_display +from ledgrab.storage.activity_log import ActivityCategory, ActivitySeverity from ledgrab.utils import get_logger from ledgrab.utils.net_classify import is_loopback as _classify_is_loopback logger = get_logger(__name__) +# ── Auth-failure audit throttle (H3) ─────────────────────────────────────── +# +# Unauthenticated callers can hammer any auth path; without a recording +# throttle each attempt would write one SQLite row AND broadcast one WS event, +# providing a cheap disk/broadcast amplification vector. +# +# Mitigation: record at most one ``auth.rejected`` audit entry per client IP +# per _AUTH_RECORD_WINDOW seconds. The auth decision (401) is NEVER +# suppressed — only the *audit recording* is de-duplicated. +# +# Memory safety: the throttle dict is capped at _AUTH_THROTTLE_HARD_CAP +# entries. When the cap is exceeded the oldest-seen IP (lowest timestamp) is +# evicted so the dict stays bounded regardless of the number of distinct source +# IPs an attacker can forge. + +_AUTH_RECORD_WINDOW: float = 10.0 # seconds — one record per IP per window +_AUTH_THROTTLE_HARD_CAP: int = 512 # max IPs tracked simultaneously + +# ip -> monotonic timestamp of last *recorded* auth.rejected entry +_auth_record_last: dict[str, float] = {} + + +def _should_record_auth_failure(client_ip: str) -> bool: + """Return True when an ``auth.rejected`` record should be written for *client_ip*. + + Suppresses duplicates within _AUTH_RECORD_WINDOW seconds. Evicts the + oldest entry when the dict exceeds _AUTH_THROTTLE_HARD_CAP to prevent + unbounded memory growth under IP-spray attacks. + """ + now = time.monotonic() + last = _auth_record_last.get(client_ip) + if last is not None and (now - last) < _AUTH_RECORD_WINDOW: + return False # suppress: within the de-dup window + + # Enforce hard cap before inserting: evict the single oldest entry. + if len(_auth_record_last) >= _AUTH_THROTTLE_HARD_CAP: + oldest_ip = min(_auth_record_last, key=lambda ip: _auth_record_last[ip]) + del _auth_record_last[oldest_ip] + + _auth_record_last[client_ip] = now + return True + + +def _record_auth_failure(reason: str, client_host: str | None) -> None: + """Best-effort: record an auth failure audit entry (never raises). + + SECURITY: the attempted token is NEVER passed here; only the reason and + the caller's IP/label are recorded. + + THROTTLE: at most one ``auth.rejected`` record is written per client IP + per _AUTH_RECORD_WINDOW seconds to prevent disk/WS-broadcast amplification + DoS. The 401 response is always returned regardless. + """ + if not _should_record_auth_failure(client_host or "unknown"): + return # throttled — drop duplicate recording for this IP/window + + from ledgrab.core.activity_log.recorder import get_module_recorder + + rec = get_module_recorder() + if rec is None: + return + rec.record( + category=ActivityCategory.AUTH, + action="auth.rejected", + severity=ActivitySeverity.WARNING, + actor="anonymous", + message=f"Authentication failed: {reason}", + metadata={"reason": reason, "client": client_host or "unknown"}, + ) + + +def _record_ws_auth_success(label: str, client_host: str | None) -> None: + """Best-effort: record a successful WebSocket session establishment.""" + from ledgrab.core.activity_log.recorder import get_module_recorder + + rec = get_module_recorder() + if rec is None: + return + rec.record( + category=ActivityCategory.AUTH, + action="auth.ws_connected", + severity=ActivitySeverity.INFO, + actor=label, + message=f"WebSocket session established by '{label}'", + metadata={"client": client_host or "unknown"}, + ) + + # Security scheme for Bearer token security = HTTPBearer(auto_error=False) @@ -81,10 +174,12 @@ def verify_api_key( # No keys configured — allow loopback only. if _is_loopback(client_host): request.state.auth_label = "anonymous" + current_actor.set("anonymous") return "anonymous" # Allow caller to authenticate explicitly even without configured keys? # No — there are no keys to compare against. Reject. logger.warning("Rejected LAN request from %s: no API key configured", client_host) + _record_auth_failure("LAN access rejected: no API key configured", client_host) raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail=( @@ -97,13 +192,14 @@ def verify_api_key( # Check if credentials are provided if not credentials: logger.warning("Request missing Authorization header") + _record_auth_failure("missing Bearer token", client_host) raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing API key - authentication is required", headers={"WWW-Authenticate": "Bearer"}, ) - # Extract token + # Extract token — NEVER log or record the token value itself. token = credentials.credentials # Find matching key and return its label using constant-time comparison @@ -115,6 +211,7 @@ def verify_api_key( if not authenticated_as: logger.warning("Invalid API key attempt") + _record_auth_failure("invalid Bearer token", client_host) raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key", @@ -127,6 +224,9 @@ def verify_api_key( # Stash the friendly label so the access-log middleware can attribute the # request to a client without re-running the token comparison. request.state.auth_label = authenticated_as + # Set the actor ContextVar so ActivityRecorder can resolve it without + # threading it through every call site. + current_actor.set(authenticated_as) return authenticated_as @@ -190,12 +290,30 @@ async def accept_and_authenticate_ws(websocket: WebSocket, timeout: float = 3.0) # a strong signal even before the token check. Non-browser clients # legitimately omit Origin; those fall through to the auth handshake. config = get_config() + client_host = websocket.client.host if websocket.client else None origin = websocket.headers.get("origin") if not _is_origin_allowed(origin, config.server.cors_origins): logger.warning( "Rejected WebSocket from origin %r (not in cors_origins)", origin, ) + # Sanitize first so urlparse does not choke on control chars / ANSI / NUL + # embedded by an attacker in the Origin header (e.g. \n triggers IPv6 parse + # error in Python's urlsplit on malformed netloc). + _safe_origin_raw = sanitize_display(origin) if origin else "" + try: + _netloc = urlparse(_safe_origin_raw).netloc if _safe_origin_raw else "" + except ValueError: + # Malformed IPv6 addresses (e.g. "http://[::1" without closing "]") + # cause urlparse to raise ValueError. Fall back to "unknown" — do NOT + # fall back to the raw origin string, which could carry query params + # or path components containing secrets. + _netloc = "" + _safe_origin = sanitize_display(_netloc or "unknown") + _record_auth_failure( + f"WebSocket origin rejected: {_safe_origin!r}", + client_host, + ) try: await websocket.close(code=WS_ORIGIN_CLOSE_CODE) except _WS_SEND_BENIGN_EXC: @@ -210,6 +328,7 @@ async def accept_and_authenticate_ws(websocket: WebSocket, timeout: float = 3.0) except _WS_SEND_BENIGN_EXC: pass return None + _record_ws_auth_success(label, client_host) return label @@ -275,6 +394,7 @@ async def verify_ws_auth( return None return "anonymous" logger.warning("WebSocket auth timeout after %.1fs from %s", timeout, client_host) + _record_auth_failure("WebSocket auth timeout", client_host) try: await websocket.send_json({"type": "auth_error", "reason": "auth timeout"}) except _WS_SEND_BENIGN_EXC: @@ -332,6 +452,7 @@ async def verify_ws_auth( await websocket.send_json({"type": "auth_ok"}) return "anonymous" logger.warning("Rejected LAN WebSocket from %s: no API key configured", client_host) + _record_auth_failure("LAN WebSocket rejected: no API key configured", client_host) try: await websocket.send_json( { @@ -343,10 +464,11 @@ async def verify_ws_auth( pass return None - # Keys configured: require a matching token. + # Keys configured: require a matching token. NEVER log the token value. label = _match_api_key(token or "") if not label: logger.warning("Invalid WebSocket auth attempt from %s", client_host) + _record_auth_failure("invalid WebSocket token", client_host) try: await websocket.send_json({"type": "auth_error", "reason": "invalid token"}) except _WS_SEND_BENIGN_EXC: diff --git a/server/src/ledgrab/api/dependencies.py b/server/src/ledgrab/api/dependencies.py index 0a1f15d..470b152 100644 --- a/server/src/ledgrab/api/dependencies.py +++ b/server/src/ledgrab/api/dependencies.py @@ -42,6 +42,11 @@ from ledgrab.core.mqtt.mqtt_manager import MQTTManager from ledgrab.storage.http_endpoint_store import HTTPEndpointStore from ledgrab.storage.audio_processing_template_store import AudioProcessingTemplateStore from ledgrab.storage.pattern_template_store import PatternTemplateStore +from ledgrab.core.activity_log.recorder import ActivityRecorder, get_module_recorder +from ledgrab.core.activity_log.retention import ActivityLogRetentionEngine +from ledgrab.core.activity_log.sanitize import sanitize_display +from ledgrab.storage.activity_log import ActivityCategory, ActivitySeverity +from ledgrab.storage.activity_log_repository import ActivityLogRepository T = TypeVar("T") @@ -196,16 +201,83 @@ def get_update_service() -> UpdateService: return _get("update_service", "Update service") +def get_activity_recorder() -> ActivityRecorder: + return _get("activity_recorder", "Activity recorder") + + +def get_activity_log_repo() -> ActivityLogRepository: + return _get("activity_log_repo", "Activity log repository") + + +def get_activity_log_retention_engine() -> ActivityLogRetentionEngine: + return _get("activity_log_retention_engine", "Activity log retention engine") + + # ── Event helper ──────────────────────────────────────────────────────── -def fire_entity_event(entity_type: str, action: str, entity_id: str) -> None: - """Fire an entity_changed event via the ProcessorManager event bus. +def _resolve_entity_name(entity_type: str, entity_id: str) -> str | None: + """Best-effort: look up a human name for *entity_id* from the matching store. + + Returns ``None`` when the store is missing, the entity is gone, or any + exception occurs (e.g. during delete the entity may have just been removed). + """ + # Map entity_type → (_deps key, method name on the store) + _STORE_LOOKUP: dict[str, tuple[str, str]] = { + "output_target": ("output_target_store", "get_target"), + "device": ("device_store", "get_device"), + "picture_source": ("picture_source_store", "get_source"), + "audio_source": ("audio_source_store", "get_source"), + "color_strip_source": ("color_strip_store", "get_source"), + "template": ("template_store", "get_template"), + "capture_template": ("template_store", "get_template"), + "pp_template": ("pp_template_store", "get_template"), + "automation": ("automation_store", "get_automation"), + "scene_preset": ("scene_preset_store", "get_preset"), + "scene_playlist": ("scene_playlist_store", "get_playlist"), + "sync_clock": ("sync_clock_store", "get_clock"), + "gradient": ("gradient_store", "get_gradient"), + "audio_template": ("audio_template_store", "get_template"), + "value_source": ("value_source_store", "get_source"), + "cspt": ("cspt_store", "get_template"), + "audio_processing_template": ("audio_processing_template_store", "get_template"), + "pattern_template": ("pattern_template_store", "get_template"), + "home_assistant_source": ("ha_store", "get_source"), + "mqtt_source": ("mqtt_store", "get_source"), + "http_endpoint": ("http_endpoint_store", "get_endpoint"), + } + entry = _STORE_LOOKUP.get(entity_type) + if entry is None: + return None + store_key, method_name = entry + store = _deps.get(store_key) + if store is None: + return None + try: + obj = getattr(store, method_name)(entity_id) + if obj is not None: + return getattr(obj, "name", None) + except Exception: + pass + return None + + +def fire_entity_event( + entity_type: str, + action: str, + entity_id: str, + entity_name: str | None = None, +) -> None: + """Fire an entity_changed event via the ProcessorManager event bus and + record an audit entry. Args: entity_type: e.g. "device", "output_target", "color_strip_source" action: "created", "updated", or "deleted" entity_id: The entity's unique ID + entity_name: Human-readable name. For deletes: **must** be passed + explicitly (entity is already gone when we get here). + For create/update: resolved from the store when not supplied. """ pm = _deps.get("processor_manager") if pm is not None: @@ -218,6 +290,38 @@ def fire_entity_event(entity_type: str, action: str, entity_id: str) -> None: } ) + # ── Audit record (best-effort) ────────────────────────────────────────── + rec = get_module_recorder() + if rec is None: + return + + # Resolve name when not explicitly provided (create / update paths). + # For deleted: entity already gone — rely on the explicitly passed name. + resolved_name = entity_name + if resolved_name is None and action != "deleted": + resolved_name = _resolve_entity_name(entity_type, entity_id) + + # Build a concise human message. + # Sanitize the display name before interpolating into the free-text message + # (user-authored names hit the CSV/export trust surface). + safe_display_name = sanitize_display(resolved_name) if resolved_name else None + display_name = f"'{safe_display_name}'" if safe_display_name else entity_id + action_word = {"created": "created", "updated": "updated", "deleted": "deleted"}.get( + action, action + ) + entity_label = entity_type.replace("_", " ") + message = f"{entity_label.capitalize()} {display_name} {action_word}" + + rec.record( + category=ActivityCategory.ENTITY, + action=f"entity.{action}", + severity=ActivitySeverity.INFO, + entity_type=entity_type, + entity_id=entity_id, + entity_name=sanitize_display(resolved_name) if resolved_name else None, + message=message, + ) + # ── Initialization ────────────────────────────────────────────────────── @@ -257,6 +361,9 @@ def init_dependencies( http_endpoint_store: HTTPEndpointStore | None = None, audio_processing_template_store: AudioProcessingTemplateStore | None = None, pattern_template_store: PatternTemplateStore | None = None, + activity_recorder: ActivityRecorder | None = None, + activity_log_repo: ActivityLogRepository | None = None, + activity_log_retention_engine: ActivityLogRetentionEngine | None = None, ): """Initialize global dependencies.""" _deps.update( @@ -295,5 +402,8 @@ def init_dependencies( "http_endpoint_store": http_endpoint_store, "audio_processing_template_store": audio_processing_template_store, "pattern_template_store": pattern_template_store, + "activity_recorder": activity_recorder, + "activity_log_repo": activity_log_repo, + "activity_log_retention_engine": activity_log_retention_engine, } ) diff --git a/server/src/ledgrab/api/routes/activity_log.py b/server/src/ledgrab/api/routes/activity_log.py new file mode 100644 index 0000000..d3c9773 --- /dev/null +++ b/server/src/ledgrab/api/routes/activity_log.py @@ -0,0 +1,436 @@ +"""Activity-log REST API — query / filter / export / settings / clear. + +Endpoints +--------- +GET /api/v1/activity-log List (filterable, keyset-paginated) +GET /api/v1/activity-log/export Streaming CSV or JSON export +GET /api/v1/activity-log/settings Retention settings +PUT /api/v1/activity-log/settings Update retention settings (requires non-anonymous auth) +DELETE /api/v1/activity-log Clear all entries (requires non-anonymous auth) + +Auth posture +------------ +- List + read settings (``GET``): ``AuthRequired`` (loopback-anonymous is fine). +- Export, update settings (``PUT``), and clear: ``require_authenticated()`` + (loopback-anonymous is rejected; mirrors the backup download / secret-reveal + pattern from ``backup.py``). Updating settings can disable auditing or prune + the trail, so it is gated like the destructive clear. + +CSV injection +------------- +Cells that begin with =, +, -, @, TAB, or CR can trigger formula execution in +spreadsheet apps (OWASP Formula Injection). ``_csv_safe`` prefixes any such cell +with a single quote so formulas are inert. Fields already go through +``sanitize_display`` in Phase 3 instrumentation, but the CSV writer applies its +own guard as defence-in-depth. + +Export generator + lock +----------------------- +``repo.iter_export()`` fetches rows in bounded batches, holding the DB ``_lock`` +only around each batch fetch and releasing it before yielding — so a slow or +stalled client never blocks other DB operations. The ``StreamingResponse`` +generator is wrapped in a ``try/finally`` block so the batch generator is closed +even when the client disconnects mid-stream. +""" + +from __future__ import annotations + +import csv +import io +import json +from datetime import datetime, timezone +from typing import Annotated, Iterator + +from fastapi import APIRouter, Depends, Query +from fastapi.responses import StreamingResponse + +from ledgrab.api.auth import AuthRequired, require_authenticated +from ledgrab.api.dependencies import ( + get_activity_log_repo, + get_activity_log_retention_engine, + get_activity_recorder, +) +from ledgrab.api.schemas.activity_log import ( + ActivityLogPageResponse, + ActivityLogSettingsResponse, + UpdateActivityLogSettingsRequest, +) +from ledgrab.core.activity_log.recorder import ActivityRecorder, entry_to_dict +from ledgrab.core.activity_log.retention import ActivityLogRetentionEngine +from ledgrab.storage.activity_log import ActivityCategory, ActivityLogFilters, ActivitySeverity +from ledgrab.storage.activity_log_repository import ActivityLogRepository + +router = APIRouter(prefix="/api/v1/activity-log", tags=["Activity Log"]) + +# Hard cap on the per-request limit to prevent runaway queries. +_MAX_LIMIT = 200 +_DEFAULT_LIMIT = 50 + +# CSV export columns (matches entry_to_dict key order) +_CSV_COLUMNS = [ + "id", + "ts", + "category", + "action", + "severity", + "actor", + "entity_type", + "entity_id", + "entity_name", + "message", + "metadata", +] + +# Characters that trigger formula injection in spreadsheet apps (OWASP). +# Leading TAB and CR are also recognised triggers by Excel / Google Sheets. +_FORMULA_PREFIXES = ("=", "+", "-", "@", "\t", "\r") + + +def _csv_safe(value: str) -> str: + """Prefix formula-injection triggers with a literal single-quote. + + A cell starting with =, +, -, or @ can execute as a formula in Excel / + Google Sheets. OWASP recommends prepending a single quote to neutralise it. + """ + if value and value[0] in _FORMULA_PREFIXES: + return "'" + value + return value + + +def _build_filters( + categories: list[str] | None, + severities: list[str] | None, + actor: str | None, + entity_type: str | None, + entity_id: str | None, + since: datetime | None, + until: datetime | None, + q: str | None, +) -> ActivityLogFilters: + """Assemble an ``ActivityLogFilters`` dataclass from query parameters.""" + return ActivityLogFilters( + categories=categories or None, + severities=severities or None, + actor=actor or None, + entity_type=entity_type or None, + entity_id=entity_id or None, + since=since, + until=until, + message_like=q or None, + ) + + +# --------------------------------------------------------------------------- +# GET /api/v1/activity-log — list +# --------------------------------------------------------------------------- + + +@router.get("", response_model=ActivityLogPageResponse, summary="List activity-log entries") +def list_activity_log( + auth: AuthRequired, # noqa: ARG001 + repo: ActivityLogRepository = Depends(get_activity_log_repo), + # ── Filters ──────────────────────────────────────────────────────────── + categories: Annotated[ + list[str] | None, + Query( + description=( + "Filter by category (repeatable or comma-separated). " + "Values: auth, device, entity, capture, system" + ) + ), + ] = None, + severities: Annotated[ + list[str] | None, + Query(description="Filter by severity (repeatable). Values: info, warning, error"), + ] = None, + actor: Annotated[ + str | None, + Query(description="Filter by actor label (exact match)"), + ] = None, + entity_type: Annotated[ + str | None, + Query(description="Filter by entity type (exact match)"), + ] = None, + entity_id: Annotated[ + str | None, + Query(description="Filter by entity id (exact match)"), + ] = None, + since: Annotated[ + datetime | None, + Query(description="Return entries at or after this ISO-8601 datetime"), + ] = None, + until: Annotated[ + datetime | None, + Query(description="Return entries at or before this ISO-8601 datetime"), + ] = None, + q: Annotated[ + str | None, + Query(description="Free-text search in the message field (substring)"), + ] = None, + # ── Pagination ───────────────────────────────────────────────────────── + before_seq: Annotated[ + int | None, + Query( + description=( + "Keyset cursor: pass the 'next_before_seq' from the previous page " + "to get the following (older) page. Omit for the first (newest) page." + ) + ), + ] = None, + limit: Annotated[ + int, + Query( + ge=1, + le=_MAX_LIMIT, + description=f"Max entries per page (default {_DEFAULT_LIMIT}, max {_MAX_LIMIT})", + ), + ] = _DEFAULT_LIMIT, +) -> ActivityLogPageResponse: + """Return the newest matching entries, oldest-first within the page. + + Keyset pagination: the response includes ``next_before_seq`` — pass it + as ``before_seq`` in the next request to get the next (older) page. + The ``total`` field is the count of all entries matching the current + filters across all pages. + """ + filters = _build_filters(categories, severities, actor, entity_type, entity_id, since, until, q) + + # Fetch limit+1 rows to detect whether an older page exists. + # + # query() fetches DESC internally (newest-first) then reverses to ascending. + # With limit+1, the result is ascending: [oldest_probe, ..., newest]. + # When we got exactly limit+1 rows, has_more is True and the probe row + # (index 0 — the oldest) is the extra one. We keep the newest `limit` rows + # by slicing [1:], which is the actual page content for the client. + # When we got <= limit rows, this is the last page and all rows are included. + effective_limit = min(limit, _MAX_LIMIT) + entries_plus = repo.query(filters, before_seq=before_seq, limit=effective_limit + 1) + has_more = len(entries_plus) > effective_limit + if has_more: + # Drop the oldest probe row; keep the newest `limit` entries. + entries = entries_plus[1:] + else: + entries = entries_plus + + total = repo.count(filters) + + # Compute next_before_seq: the seq of the oldest entry on this page. + # query() returns entries ascending (entries[0] is oldest); its seq is the + # cursor for the next page. The next request passes before_seq=X to get + # entries with seq < X, i.e. entries older than the oldest entry on this page. + # get_seq_for_id() does a cheap indexed point-lookup. + next_before_seq: int | None = None + if has_more and entries: + next_before_seq = repo.get_seq_for_id(entries[0].id) + + return ActivityLogPageResponse( + entries=[entry_to_dict(e) for e in entries], # type: ignore[arg-type] + next_before_seq=next_before_seq, + has_more=has_more, + total=total, + ) + + +# --------------------------------------------------------------------------- +# GET /api/v1/activity-log/export — streaming export (CSV or JSON) +# --------------------------------------------------------------------------- + + +def _export_csv_generator( + repo: ActivityLogRepository, + filters: ActivityLogFilters, +) -> Iterator[bytes]: + """Yield UTF-8-encoded CSV chunks one row at a time. + + The generator wraps ``repo.iter_export()`` in a ``try/finally`` so the DB + lock is released even on early client disconnect (which triggers + ``GeneratorExit``). + """ + gen = repo.iter_export(filters) + try: + # Header + buf = io.StringIO() + writer = csv.writer(buf) + writer.writerow(_CSV_COLUMNS) + yield buf.getvalue().encode("utf-8") + + for entry in gen: + d = entry_to_dict(entry) + row = [] + for col in _CSV_COLUMNS: + if col == "metadata": + cell = json.dumps(d.get(col) or {}) + else: + cell = str(d.get(col, "") or "") + row.append(_csv_safe(cell)) + buf = io.StringIO() + writer = csv.writer(buf) + writer.writerow(row) + yield buf.getvalue().encode("utf-8") + finally: + gen.close() + + +def _export_json_generator( + repo: ActivityLogRepository, + filters: ActivityLogFilters, +) -> Iterator[bytes]: + """Yield a streamed JSON array, one entry per chunk. + + Format: ``[\\n{entry},\\n{entry},\\n...]\\n`` + The generator wraps ``repo.iter_export()`` in a ``try/finally`` so the DB + lock is released even on early client disconnect. + """ + gen = repo.iter_export(filters) + try: + first = True + yield b"[\n" + for entry in gen: + d = entry_to_dict(entry) + chunk = json.dumps(d, ensure_ascii=False, default=str) + if first: + yield chunk.encode("utf-8") + first = False + else: + yield b",\n" + chunk.encode("utf-8") + yield b"\n]\n" + finally: + gen.close() + + +@router.get("/export", summary="Export activity-log entries (streaming CSV or JSON)") +def export_activity_log( + auth: AuthRequired, + repo: ActivityLogRepository = Depends(get_activity_log_repo), + # ── Format ──────────────────────────────────────────────────────────── + format: Annotated[ + str, + Query(description="Export format: 'csv' or 'json'"), + ] = "csv", + # ── Same filters as list ─────────────────────────────────────────────── + categories: Annotated[list[str] | None, Query()] = None, + severities: Annotated[list[str] | None, Query()] = None, + actor: Annotated[str | None, Query()] = None, + entity_type: Annotated[str | None, Query()] = None, + entity_id: Annotated[str | None, Query()] = None, + since: Annotated[datetime | None, Query()] = None, + until: Annotated[datetime | None, Query()] = None, + q: Annotated[str | None, Query()] = None, +) -> StreamingResponse: + """Stream all matching entries as CSV or JSON. + + Requires a non-anonymous API key (loopback-anonymous access is rejected + because the log may contain IP addresses and entity names). + """ + require_authenticated(auth) + + if format not in ("csv", "json"): + from fastapi import HTTPException + + raise HTTPException( + status_code=422, + detail="'format' must be 'csv' or 'json'", + ) + + filters = _build_filters(categories, severities, actor, entity_type, entity_id, since, until, q) + + timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H%M%S") + + if format == "csv": + filename = f"activity-log-{timestamp}.csv" + media_type = "text/csv; charset=utf-8" + generator = _export_csv_generator(repo, filters) + else: + filename = f"activity-log-{timestamp}.json" + media_type = "application/json" + generator = _export_json_generator(repo, filters) + + return StreamingResponse( + generator, + media_type=media_type, + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) + + +# --------------------------------------------------------------------------- +# GET /api/v1/activity-log/settings +# PUT /api/v1/activity-log/settings +# --------------------------------------------------------------------------- + + +@router.get( + "/settings", + response_model=ActivityLogSettingsResponse, + summary="Get activity-log retention settings", +) +def get_activity_log_settings( + _: AuthRequired, + engine: ActivityLogRetentionEngine = Depends(get_activity_log_retention_engine), +) -> ActivityLogSettingsResponse: + """Return the current activity-log retention settings.""" + return ActivityLogSettingsResponse(**engine.get_settings()) + + +@router.put( + "/settings", + response_model=ActivityLogSettingsResponse, + summary="Update activity-log retention settings", +) +async def update_activity_log_settings( + auth: AuthRequired, + body: UpdateActivityLogSettingsRequest, + engine: ActivityLogRetentionEngine = Depends(get_activity_log_retention_engine), +) -> ActivityLogSettingsResponse: + """Update the activity-log retention settings (applied immediately). + + Requires a non-anonymous API key (loopback-anonymous access is rejected) + because disabling the log or pruning retention is equivalent in impact to + clearing the audit trail. + + Setting ``enabled=false`` records an audit entry BEFORE the flag takes + effect so the last entry in the log shows who disabled recording. + """ + require_authenticated(auth) + result = await engine.update_settings( + enabled=body.enabled, + max_days=body.max_days, + max_entries=body.max_entries, + ) + return ActivityLogSettingsResponse(**result) + + +# --------------------------------------------------------------------------- +# DELETE /api/v1/activity-log — clear +# --------------------------------------------------------------------------- + + +@router.delete("", summary="Clear all activity-log entries") +def clear_activity_log( + auth: AuthRequired, + repo: ActivityLogRepository = Depends(get_activity_log_repo), + recorder: ActivityRecorder = Depends(get_activity_recorder), +) -> dict: + """Delete all activity-log entries. + + Requires a non-anonymous API key (loopback-anonymous access is rejected). + The clear operation itself is audited — a ``system/activity_log_cleared`` + entry is recorded AFTER the wipe, so the log shows who cleared it and how + many rows were removed. + + Returns ``{"deleted": }``. + """ + require_authenticated(auth) + + deleted = repo.clear() + + # Record the clear action (best-effort — recorder never raises). + recorder.record( + category=ActivityCategory.SYSTEM, + action="activity_log.cleared", + severity=ActivitySeverity.INFO, + actor=auth, + message=f"Activity log cleared ({deleted} entries removed)", + metadata={"deleted_count": deleted}, + ) + + return {"deleted": deleted} diff --git a/server/src/ledgrab/api/routes/audio_sources.py b/server/src/ledgrab/api/routes/audio_sources.py index 704900e..8d252fc 100644 --- a/server/src/ledgrab/api/routes/audio_sources.py +++ b/server/src/ledgrab/api/routes/audio_sources.py @@ -182,6 +182,12 @@ async def delete_audio_source( css_store: ColorStripStore = Depends(get_color_strip_store), ): """Delete an audio source.""" + _entity_name: str | None = None + try: + _entity_name = store.get_source(source_id).name + except Exception: + pass + try: # Check if any CSS entities reference this audio source from ledgrab.storage.color_strip_source import AudioColorStripSource @@ -194,7 +200,7 @@ async def delete_audio_source( raise ValueError(f"Cannot delete: referenced by color strip source '{css.name}'") store.delete_source(source_id) - fire_entity_event("audio_source", "deleted", source_id) + fire_entity_event("audio_source", "deleted", source_id, entity_name=_entity_name) except EntityNotFoundError as e: raise HTTPException(status_code=404, detail=str(e)) diff --git a/server/src/ledgrab/api/routes/automations.py b/server/src/ledgrab/api/routes/automations.py index 3208009..cade7df 100644 --- a/server/src/ledgrab/api/routes/automations.py +++ b/server/src/ledgrab/api/routes/automations.py @@ -329,6 +329,12 @@ async def delete_automation( engine: AutomationEngine = Depends(get_automation_engine), ): """Delete an automation.""" + _entity_name: str | None = None + try: + _entity_name = store.get_automation(automation_id).name + except Exception: + pass + # Deactivate first await engine.deactivate_if_active(automation_id) @@ -337,7 +343,7 @@ async def delete_automation( except ValueError as e: raise HTTPException(status_code=404, detail=str(e)) - fire_entity_event("automation", "deleted", automation_id) + fire_entity_event("automation", "deleted", automation_id, entity_name=_entity_name) # ===== Enable/Disable ===== diff --git a/server/src/ledgrab/api/routes/backup.py b/server/src/ledgrab/api/routes/backup.py index 189f2ab..9951da9 100644 --- a/server/src/ledgrab/api/routes/backup.py +++ b/server/src/ledgrab/api/routes/backup.py @@ -27,6 +27,7 @@ from ledgrab.api.schemas.system import ( ) from ledgrab.config import get_config from ledgrab.core.backup.auto_backup import AutoBackupEngine +from ledgrab.storage.activity_log import ActivityCategory, ActivitySeverity from ledgrab.storage.asset_store import AssetStore from ledgrab.storage.database import Database, freeze_writes from ledgrab.utils import get_logger, read_upload_capped @@ -35,6 +36,22 @@ logger = get_logger(__name__) router = APIRouter() + +def _record_system(action: str, message: str, metadata: dict | None = None) -> None: + """Best-effort audit record for a system-level event.""" + from ledgrab.core.activity_log.recorder import get_module_recorder + + rec = get_module_recorder() + if rec is not None: + rec.record( + category=ActivityCategory.SYSTEM, + action=action, + severity=ActivitySeverity.INFO, + message=message, + metadata=metadata or {}, + ) + + _SERVER_DIR = Path(__file__).resolve().parents[4] @@ -143,6 +160,8 @@ def backup_config( timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H%M%S") filename = f"ledgrab-backup-{timestamp}.zip" + _record_system("backup.created", f"Backup downloaded: {filename}", {"filename": filename}) + return StreamingResponse( zip_buffer, media_type="application/zip", @@ -243,6 +262,7 @@ async def restore_config( freeze_writes() logger.info("Database restored from uploaded backup. Scheduling restart...") + _record_system("backup.restored", "Database restored from uploaded backup") _schedule_restart() return RestoreResponse( @@ -257,6 +277,7 @@ def restart_server(_: AuthRequired): """Schedule a server restart and return immediately.""" from ledgrab.server_ref import _broadcast_restarting + _record_system("server.restarting", "Server restart requested by user") _broadcast_restarting() _schedule_restart() return {"status": "restarting"} @@ -267,6 +288,7 @@ def shutdown_server(_: AuthRequired): """Gracefully shut down the server.""" from ledgrab.server_ref import request_shutdown + _record_system("server.shutdown_requested", "Server shutdown requested by user") request_shutdown() return {"status": "shutting_down"} @@ -300,11 +322,17 @@ async def update_auto_backup_settings( engine: AutoBackupEngine = Depends(get_auto_backup_engine), ): """Update auto-backup settings (enable/disable, interval, max backups).""" - return await engine.update_settings( + result = await engine.update_settings( enabled=body.enabled, interval_hours=body.interval_hours, max_backups=body.max_backups, ) + _record_system( + "settings.changed", + f"Auto-backup settings updated (enabled={body.enabled})", + {"setting_key": "auto_backup", "enabled": body.enabled}, + ) + return result @router.post("/api/v1/system/auto-backup/trigger", tags=["System"]) @@ -365,4 +393,5 @@ async def delete_saved_backup( engine.delete_backup(filename) except (ValueError, FileNotFoundError) as e: raise HTTPException(status_code=404, detail=str(e)) + _record_system("backup.deleted", f"Saved backup deleted: {filename}", {"filename": filename}) return {"status": "deleted", "filename": filename} diff --git a/server/src/ledgrab/api/routes/calibration.py b/server/src/ledgrab/api/routes/calibration.py index 4080e5f..b403711 100644 --- a/server/src/ledgrab/api/routes/calibration.py +++ b/server/src/ledgrab/api/routes/calibration.py @@ -36,6 +36,7 @@ from fastapi import APIRouter, Depends, HTTPException from ledgrab.api.auth import AuthRequired from ledgrab.api.dependencies import get_processor_manager +from ledgrab.storage.activity_log import ActivityCategory, ActivitySeverity from ledgrab.api.schemas.calibration import ( CalibrationSessionPositionRequest, CalibrationSessionStartRequest, @@ -81,6 +82,19 @@ async def start_calibration_session( logger.error("Failed to start calibration session: %s", exc, exc_info=True) raise HTTPException(status_code=500, detail="Internal server error") + from ledgrab.core.activity_log.recorder import get_module_recorder + + rec = get_module_recorder() + if rec is not None: + rec.record( + category=ActivityCategory.SYSTEM, + action="calibration.started", + severity=ActivitySeverity.INFO, + entity_type="device", + entity_id=body.device_id, + message=f"Calibration session started for device '{body.device_id}'", + ) + return CalibrationSessionStateResponse(**session.get_state()) @@ -135,6 +149,17 @@ async def stop_calibration_session( logger.error("Failed to stop calibration session: %s", exc, exc_info=True) raise HTTPException(status_code=500, detail="Internal server error") + from ledgrab.core.activity_log.recorder import get_module_recorder + + rec = get_module_recorder() + if rec is not None: + rec.record( + category=ActivityCategory.SYSTEM, + action="calibration.stopped", + severity=ActivitySeverity.INFO, + message="Calibration session stopped", + ) + return CalibrationSessionStateResponse(**session.get_state()) @@ -155,6 +180,17 @@ async def cancel_calibration_session( logger.error("Failed to cancel calibration session: %s", exc, exc_info=True) raise HTTPException(status_code=500, detail="Internal server error") + from ledgrab.core.activity_log.recorder import get_module_recorder + + rec = get_module_recorder() + if rec is not None: + rec.record( + category=ActivityCategory.SYSTEM, + action="calibration.cancelled", + severity=ActivitySeverity.INFO, + message="Calibration session cancelled", + ) + return CalibrationSessionStateResponse(**session.get_state()) diff --git a/server/src/ledgrab/api/routes/color_strip_sources/crud.py b/server/src/ledgrab/api/routes/color_strip_sources/crud.py index 3638be6..309044f 100644 --- a/server/src/ledgrab/api/routes/color_strip_sources/crud.py +++ b/server/src/ledgrab/api/routes/color_strip_sources/crud.py @@ -167,6 +167,12 @@ async def delete_color_strip_source( target_store: OutputTargetStore = Depends(get_output_target_store), ): """Delete a color strip source. Returns 409 if referenced by any LED target.""" + _entity_name: str | None = None + try: + _entity_name = store.get_source(source_id).name + except Exception: + pass + try: target_names = target_store.get_targets_referencing_css(source_id) if target_names: @@ -201,7 +207,7 @@ async def delete_color_strip_source( "Delete or reassign the processed source(s) first.", ) store.delete_source(source_id) - fire_entity_event("color_strip_source", "deleted", source_id) + fire_entity_event("color_strip_source", "deleted", source_id, entity_name=_entity_name) except HTTPException: raise except ValueError as e: diff --git a/server/src/ledgrab/api/routes/devices.py b/server/src/ledgrab/api/routes/devices.py index 8edc0ea..e9d4d8a 100644 --- a/server/src/ledgrab/api/routes/devices.py +++ b/server/src/ledgrab/api/routes/devices.py @@ -701,6 +701,13 @@ async def delete_device( ): """Delete/detach a device. Returns 409 if referenced by a target.""" try: + # Resolve name before deletion for the audit record. + _entity_name: str | None = None + try: + _entity_name = store.get_device(device_id).name + except Exception: + pass + # Check if any target references this device refs = target_store.get_targets_for_device(device_id) if refs: @@ -728,7 +735,7 @@ async def delete_device( # Delete from storage store.delete_device(device_id) - fire_entity_event("device", "deleted", device_id) + fire_entity_event("device", "deleted", device_id, entity_name=_entity_name) logger.info(f"Deleted device {device_id}") except HTTPException: diff --git a/server/src/ledgrab/api/routes/game_integration.py b/server/src/ledgrab/api/routes/game_integration.py index b07f7e5..40b06ea 100644 --- a/server/src/ledgrab/api/routes/game_integration.py +++ b/server/src/ledgrab/api/routes/game_integration.py @@ -6,6 +6,7 @@ adapter metadata, and diagnostics. import threading import time +from collections import defaultdict from typing import Any from fastapi import APIRouter, Depends, HTTPException, Request @@ -57,6 +58,73 @@ _prev_states: dict[str, dict[str, Any]] = {} _integration_stats: dict[str, dict[str, Any]] = {} +# ── Failed-auth rate limiter (brute-force defence on the ingest route) ───── +# +# The ingest route is high-frequency (games push at 16-64 Hz), so we do NOT +# rate-limit every event — that would throttle legitimate gameplay traffic. +# Instead we throttle only FAILED-auth attempts per source IP (the only thing +# an attacker without the token can produce). This mirrors the IP-based +# limiter in routes/webhooks.py (~30/min) but scopes it to failures so a +# brute-forcer is locked out after _AUTH_FAIL_LIMIT bad tokens per minute +# while authenticated high-rate ingestion is completely unaffected. +_AUTH_FAIL_LIMIT = 30 +_AUTH_FAIL_WINDOW = 60.0 # seconds +_AUTH_FAIL_HITS_HARD_CAP = 1024 +_LOOPBACK_HOSTS = frozenset({"127.0.0.1", "::1", "localhost"}) +_auth_fail_hits: dict[str, list[float]] = defaultdict(list) +_auth_fail_lock = threading.Lock() + + +def _rate_limit_key(request: Request) -> str: + """Pick a stable client identifier for rate-limiting. + + When the immediate peer is loopback (assumed reverse-proxy), use the + first ``X-Forwarded-For`` entry; otherwise use the peer's IP. + """ + peer = request.client.host if request.client else "unknown" + if peer in _LOOPBACK_HOSTS: + xff = request.headers.get("x-forwarded-for", "") + if xff: + return xff.split(",", 1)[0].strip() or peer + return peer + + +def _check_auth_fail_rate_limit(client_ip: str) -> None: + """Raise 429 if *client_ip* exceeded the failed-auth attempt limit.""" + now = time.time() + window_start = now - _AUTH_FAIL_WINDOW + with _auth_fail_lock: + timestamps = [t for t in _auth_fail_hits[client_ip] if t > window_start] + _auth_fail_hits[client_ip] = timestamps + if len(timestamps) >= _AUTH_FAIL_LIMIT: + raise HTTPException( + status_code=429, + detail="Too many failed authentication attempts. Try again later.", + ) + + +def _record_auth_failure(client_ip: str) -> None: + """Record a failed-auth attempt for *client_ip* (bounded memory).""" + now = time.time() + window_start = now - _AUTH_FAIL_WINDOW + with _auth_fail_lock: + _auth_fail_hits[client_ip].append(now) + # Periodic cleanup of stale IPs to prevent unbounded growth. + if len(_auth_fail_hits) > 100: + stale = [ip for ip, ts in _auth_fail_hits.items() if not ts or ts[-1] < window_start] + for ip in stale: + del _auth_fail_hits[ip] + # Hard cap against an attacker spraying many distinct X-Forwarded-For + # values; drop the oldest-touched IPs. + if len(_auth_fail_hits) > _AUTH_FAIL_HITS_HARD_CAP: + ordered = sorted( + _auth_fail_hits.items(), + key=lambda kv: kv[1][-1] if kv[1] else 0.0, + ) + for ip, _ in ordered[: len(ordered) - _AUTH_FAIL_HITS_HARD_CAP]: + _auth_fail_hits.pop(ip, None) + + def _schema_to_fields(schema: dict[str, Any]) -> list[dict[str, Any]]: """Convert a JSON Schema object into a flat list of field descriptors. @@ -387,7 +455,16 @@ async def ingest_event( called before standard API auth. No AuthRequired dependency — adapter-level auth is used instead. + + Rate limiting is scoped to FAILED-auth attempts per source IP (see + ``_check_auth_fail_rate_limit``) so legitimate high-rate ingestion is + never throttled, but a brute-forcer is locked out after the threshold. """ + client_ip = _rate_limit_key(request) + # Block IPs that have already burned through the failed-auth budget, + # before doing any work (cheap brute-force lockout). + _check_auth_fail_rate_limit(client_ip) + try: config = store.get_integration(integration_id) except EntityNotFoundError: @@ -405,6 +482,7 @@ async def ingest_event( # Adapter-level auth check headers = dict(request.headers) if not adapter_cls.validate_auth(headers, payload.data, config.adapter_config): + _record_auth_failure(client_ip) raise HTTPException(status_code=403, detail="Adapter authentication failed") # Parse payload through adapter diff --git a/server/src/ledgrab/api/routes/gradients.py b/server/src/ledgrab/api/routes/gradients.py index c8d6e3e..9ea940a 100644 --- a/server/src/ledgrab/api/routes/gradients.py +++ b/server/src/ledgrab/api/routes/gradients.py @@ -152,13 +152,19 @@ async def delete_gradient( css_store: ColorStripStore = Depends(get_color_strip_store), ): """Delete a gradient (fails if built-in or referenced by sources).""" + _entity_name: str | None = None + try: + _entity_name = store.get_gradient(gradient_id).name + except Exception: + pass + try: # Check references for source in css_store.get_all_sources(): if getattr(source, "gradient_id", None) == gradient_id: raise ValueError(f"Cannot delete: referenced by color strip source '{source.name}'") store.delete_gradient(gradient_id) - fire_entity_event("gradient", "deleted", gradient_id) + fire_entity_event("gradient", "deleted", gradient_id, entity_name=_entity_name) except (ValueError, EntityNotFoundError) as e: status = 404 if "not found" in str(e).lower() else 400 raise HTTPException(status_code=status, detail=str(e)) diff --git a/server/src/ledgrab/api/routes/home_assistant.py b/server/src/ledgrab/api/routes/home_assistant.py index d982831..0ffeceb 100644 --- a/server/src/ledgrab/api/routes/home_assistant.py +++ b/server/src/ledgrab/api/routes/home_assistant.py @@ -2,6 +2,7 @@ import asyncio import json +from urllib.parse import urlparse from fastapi import APIRouter, Depends, HTTPException, Query @@ -28,6 +29,7 @@ from ledgrab.storage.base_store import EntityNotFoundError from ledgrab.storage.home_assistant_source import HomeAssistantSource from ledgrab.storage.home_assistant_store import HomeAssistantStore from ledgrab.utils import get_logger +from ledgrab.utils.net_classify import validate_lan_host logger = get_logger(__name__) @@ -37,6 +39,23 @@ router = APIRouter() _REDACTED_TOKEN = "***" +def _validate_ha_host(host: str | None) -> None: + """Reject literal public/link-local/metadata IPs for a HA source host. + + HA sources are LAN-by-design (loopback + private ranges allowed), so we + gate the user-supplied ``host`` with the same shared classifier the LED + device providers use (``validate_lan_host``). The HA host is stored as + ``host:port`` (e.g. ``192.168.1.100:8123``), so strip the port first via + ``urlparse`` — which also handles bracketed IPv6 literals. Hostnames / + mDNS labels pass through (classified UNPARSEABLE). Raises ``ValueError`` + on a literal public IP, which the callers translate to HTTP 400. + """ + if not host: + return + bare_host = urlparse(f"//{host.strip()}").hostname or host.strip() + validate_lan_host(bare_host) + + def _to_response( source: HomeAssistantSource, manager: HomeAssistantManager, @@ -99,6 +118,7 @@ async def create_ha_source( manager: HomeAssistantManager = Depends(get_ha_manager), ): try: + _validate_ha_host(data.host) source = store.create_source( name=data.name, host=data.host, @@ -153,6 +173,7 @@ async def update_ha_source( manager: HomeAssistantManager = Depends(get_ha_manager), ): try: + _validate_ha_host(data.host) source = store.update_source( source_id, name=data.name, diff --git a/server/src/ledgrab/api/routes/output_targets.py b/server/src/ledgrab/api/routes/output_targets.py index 7b677d8..441106f 100644 --- a/server/src/ledgrab/api/routes/output_targets.py +++ b/server/src/ledgrab/api/routes/output_targets.py @@ -624,6 +624,13 @@ async def delete_target( ): """Delete a output target. Stops processing first if active.""" try: + # Resolve name before deletion for the audit record. + _entity_name: str | None = None + try: + _entity_name = target_store.get_target(target_id).name + except Exception: + pass + # Stop processing if running try: await manager.stop_processing(target_id) @@ -641,7 +648,7 @@ async def delete_target( # Delete from store target_store.delete_target(target_id) - fire_entity_event("output_target", "deleted", target_id) + fire_entity_event("output_target", "deleted", target_id, entity_name=_entity_name) logger.info(f"Deleted target {target_id}") except ValueError as e: diff --git a/server/src/ledgrab/api/routes/output_targets_control.py b/server/src/ledgrab/api/routes/output_targets_control.py index 43eb818..5607a03 100644 --- a/server/src/ledgrab/api/routes/output_targets_control.py +++ b/server/src/ledgrab/api/routes/output_targets_control.py @@ -12,6 +12,7 @@ from ledgrab.api.dependencies import ( get_picture_source_store, get_processor_manager, ) +from ledgrab.storage.activity_log import ActivityCategory, ActivitySeverity from ledgrab.api.schemas.output_targets import ( BulkTargetRequest, BulkTargetResponse, @@ -28,6 +29,7 @@ from ledgrab.storage.color_strip_source import ( from ledgrab.storage.picture_source_store import PictureSourceStore from ledgrab.storage.wled_output_target import WledOutputTarget from ledgrab.storage.output_target_store import OutputTargetStore +from ledgrab.core.activity_log.sanitize import sanitize_display from ledgrab.utils import get_logger logger = get_logger(__name__) @@ -35,6 +37,23 @@ logger = get_logger(__name__) router = APIRouter() +def _record_capture(action: str, target_id: str, target_name: str | None, message: str) -> None: + """Best-effort audit record for a capture start/stop action.""" + from ledgrab.core.activity_log.recorder import get_module_recorder + + rec = get_module_recorder() + if rec is not None: + rec.record( + category=ActivityCategory.CAPTURE, + action=action, + severity=ActivitySeverity.INFO, + entity_type="output_target", + entity_id=target_id, + entity_name=sanitize_display(target_name) if target_name else None, + message=message, + ) + + # ===== BULK PROCESSING CONTROL ENDPOINTS ===== @@ -53,10 +72,18 @@ async def bulk_start_processing( for target_id in body.ids: try: - target_store.get_target(target_id) + _tgt = target_store.get_target(target_id) await manager.start_processing(target_id) started.append(target_id) logger.info(f"Bulk start: started processing for target {target_id}") + _tgt_name_raw = getattr(_tgt, "name", None) + _tgt_safe = sanitize_display(_tgt_name_raw) if _tgt_name_raw else None + _record_capture( + "capture.started", + target_id, + _tgt_safe, + f"Capture started for target '{_tgt_safe or target_id}' (bulk)", + ) except ValueError as e: errors[target_id] = str(e) except RuntimeError as e: @@ -78,6 +105,7 @@ async def bulk_start_processing( async def bulk_stop_processing( body: BulkTargetRequest, _auth: AuthRequired, + target_store: OutputTargetStore = Depends(get_output_target_store), manager: ProcessorManager = Depends(get_processor_manager), ): """Stop processing for multiple output targets. Returns lists of stopped IDs and per-ID errors.""" @@ -89,6 +117,18 @@ async def bulk_stop_processing( await manager.stop_processing(target_id) stopped.append(target_id) logger.info(f"Bulk stop: stopped processing for target {target_id}") + _tgt_name: str | None = None + try: + _tgt_name = target_store.get_target(target_id).name + except Exception: + pass + _tgt_name_safe = sanitize_display(_tgt_name) if _tgt_name else None + _record_capture( + "capture.stopped", + target_id, + _tgt_name_safe, + f"Capture stopped for target '{_tgt_name_safe or target_id}' (bulk)", + ) except ValueError as e: errors[target_id] = str(e) except Exception as e: @@ -112,11 +152,19 @@ async def start_processing( logger.info("Start processing requested for target %s", target_id) try: # Verify target exists in store - target_store.get_target(target_id) + target = target_store.get_target(target_id) await manager.start_processing(target_id) logger.info(f"Started processing for target {target_id}") + _tgt_name_raw2 = getattr(target, "name", None) + _tgt_safe2 = sanitize_display(_tgt_name_raw2) if _tgt_name_raw2 else None + _record_capture( + "capture.started", + target_id, + _tgt_safe2, + f"Capture started for target '{_tgt_safe2 or target_id}'", + ) return {"status": "started", "target_id": target_id} except ValueError as e: @@ -137,6 +185,7 @@ async def start_processing( async def stop_processing( target_id: str, _auth: AuthRequired, + target_store: OutputTargetStore = Depends(get_output_target_store), manager: ProcessorManager = Depends(get_processor_manager), ): """Stop processing for a output target.""" @@ -144,6 +193,18 @@ async def stop_processing( await manager.stop_processing(target_id) logger.info(f"Stopped processing for target {target_id}") + _target_name: str | None = None + try: + _target_name = target_store.get_target(target_id).name + except Exception: + pass + _target_name_safe = sanitize_display(_target_name) if _target_name else None + _record_capture( + "capture.stopped", + target_id, + _target_name_safe, + f"Capture stopped for target '{_target_name_safe or target_id}'", + ) return {"status": "stopped", "target_id": target_id} except ValueError as e: diff --git a/server/src/ledgrab/api/routes/picture_sources.py b/server/src/ledgrab/api/routes/picture_sources.py index 1d38520..45cb13d 100644 --- a/server/src/ledgrab/api/routes/picture_sources.py +++ b/server/src/ledgrab/api/routes/picture_sources.py @@ -374,6 +374,12 @@ async def delete_picture_source( css_store: ColorStripStore = Depends(get_color_strip_store), ): """Delete a picture source.""" + _entity_name: str | None = None + try: + _entity_name = store.get_stream(stream_id).name + except Exception: + pass + try: # Check if any target transitively references this stream via a CSS target_names = store.get_targets_referencing(stream_id, target_store, css_store) @@ -395,7 +401,7 @@ async def delete_picture_source( f"{css_names}. Please reassign or delete those first.", ) store.delete_stream(stream_id) - fire_entity_event("picture_source", "deleted", stream_id) + fire_entity_event("picture_source", "deleted", stream_id, entity_name=_entity_name) except HTTPException: raise except EntityNotFoundError as e: diff --git a/server/src/ledgrab/api/routes/scene_playlists.py b/server/src/ledgrab/api/routes/scene_playlists.py index 651bb55..bf22eb5 100644 --- a/server/src/ledgrab/api/routes/scene_playlists.py +++ b/server/src/ledgrab/api/routes/scene_playlists.py @@ -6,6 +6,7 @@ from datetime import datetime, timezone from fastapi import APIRouter, Depends, HTTPException from ledgrab.api.auth import AuthRequired +from ledgrab.core.activity_log.sanitize import sanitize_display from ledgrab.api.dependencies import ( fire_entity_event, get_playlist_engine, @@ -220,13 +221,19 @@ async def delete_scene_playlist( engine: PlaylistEngine = Depends(get_playlist_engine), ): """Delete a scene playlist (stops it first if it is currently cycling).""" + _entity_name: str | None = None + try: + _entity_name = store.get_playlist(playlist_id).name + except Exception: + pass + try: store.delete_playlist(playlist_id) except (ValueError, EntityNotFoundError) as e: raise HTTPException(status_code=404, detail=str(e)) await engine.stop_if_running(playlist_id) - fire_entity_event("scene_playlist", "deleted", playlist_id) + fire_entity_event("scene_playlist", "deleted", playlist_id, entity_name=_entity_name) # ===== Cycling control ===== @@ -255,6 +262,28 @@ async def start_scene_playlist( raise HTTPException(status_code=400, detail=str(e)) fire_entity_event("scene_playlist", "updated", playlist_id) + + from ledgrab.core.activity_log.recorder import get_module_recorder + from ledgrab.storage.activity_log import ActivityCategory, ActivitySeverity + + rec = get_module_recorder() + if rec is not None: + _pl_name: str | None = None + try: + _pl_name = store.get_playlist(playlist_id).name + except Exception: + pass + _safe_pl_name = sanitize_display(_pl_name) if _pl_name else None + rec.record( + category=ActivityCategory.CAPTURE, + action="playlist.started", + severity=ActivitySeverity.INFO, + entity_type="scene_playlist", + entity_id=playlist_id, + entity_name=_safe_pl_name, + message=f"Playlist '{_safe_pl_name or playlist_id}' started", + ) + return PlaylistRuntimeStateSchema(**engine.get_state()) @@ -265,11 +294,35 @@ async def start_scene_playlist( ) async def stop_scene_playlist( _auth: AuthRequired, + store: ScenePlaylistStore = Depends(get_scene_playlist_store), engine: PlaylistEngine = Depends(get_playlist_engine), ): """Stop the active playlist (leaves the last applied scene in place).""" stopped_id = engine.get_running_playlist_id() + _stopped_name: str | None = None + if stopped_id: + try: + _stopped_name = store.get_playlist(stopped_id).name + except Exception: + pass await engine.stop() if stopped_id: fire_entity_event("scene_playlist", "updated", stopped_id) + + from ledgrab.core.activity_log.recorder import get_module_recorder + from ledgrab.storage.activity_log import ActivityCategory, ActivitySeverity + + rec = get_module_recorder() + if rec is not None: + _safe_stopped_name = sanitize_display(_stopped_name) if _stopped_name else None + rec.record( + category=ActivityCategory.CAPTURE, + action="playlist.stopped", + severity=ActivitySeverity.INFO, + entity_type="scene_playlist", + entity_id=stopped_id, + entity_name=_safe_stopped_name, + message=f"Playlist '{_safe_stopped_name or stopped_id}' stopped", + ) + return PlaylistRuntimeStateSchema(**engine.get_state()) diff --git a/server/src/ledgrab/api/routes/scene_presets.py b/server/src/ledgrab/api/routes/scene_presets.py index 8e05ac8..098803d 100644 --- a/server/src/ledgrab/api/routes/scene_presets.py +++ b/server/src/ledgrab/api/routes/scene_presets.py @@ -6,6 +6,7 @@ from datetime import datetime, timezone from fastapi import APIRouter, Depends, HTTPException from ledgrab.api.auth import AuthRequired +from ledgrab.core.activity_log.sanitize import sanitize_display from ledgrab.api.dependencies import ( fire_entity_event, get_output_target_store, @@ -208,12 +209,18 @@ async def delete_scene_preset( store: ScenePresetStore = Depends(get_scene_preset_store), ): """Delete a scene preset.""" + _entity_name: str | None = None + try: + _entity_name = store.get_preset(preset_id).name + except Exception: + pass + try: store.delete_preset(preset_id) except ValueError as e: raise HTTPException(status_code=404, detail=str(e)) - fire_entity_event("scene_preset", "deleted", preset_id) + fire_entity_event("scene_preset", "deleted", preset_id, entity_name=_entity_name) # ===== Recapture ===== @@ -282,4 +289,21 @@ async def activate_scene_preset( logger.info(f"Scene preset '{preset.name}' activated successfully") fire_entity_event("scene_preset", "updated", preset_id) + + from ledgrab.core.activity_log.recorder import get_module_recorder + from ledgrab.storage.activity_log import ActivityCategory, ActivitySeverity + + rec = get_module_recorder() + if rec is not None: + _safe_preset_name = sanitize_display(preset.name) if preset.name else None + rec.record( + category=ActivityCategory.CAPTURE, + action="scene.activated", + severity=ActivitySeverity.INFO, + entity_type="scene_preset", + entity_id=preset_id, + entity_name=_safe_preset_name, + message=f"Scene preset '{_safe_preset_name or preset_id}' activated", + ) + return ActivateResponse(status=status, errors=errors) diff --git a/server/src/ledgrab/api/routes/sync_clocks.py b/server/src/ledgrab/api/routes/sync_clocks.py index 13de0e5..12ea812 100644 --- a/server/src/ledgrab/api/routes/sync_clocks.py +++ b/server/src/ledgrab/api/routes/sync_clocks.py @@ -149,6 +149,12 @@ async def delete_sync_clock( manager: SyncClockManager = Depends(get_sync_clock_manager), ): """Delete a synchronization clock (fails if referenced by CSS or value sources).""" + _entity_name: str | None = None + try: + _entity_name = store.get_clock(clock_id).name + except Exception: + pass + try: # Check references for source in css_store.get_all_sources(): @@ -159,7 +165,7 @@ async def delete_sync_clock( raise ValueError(f"Cannot delete: referenced by value source '{vs.name}'") manager.release_all_for(clock_id) store.delete_clock(clock_id) - fire_entity_event("sync_clock", "deleted", clock_id) + fire_entity_event("sync_clock", "deleted", clock_id, entity_name=_entity_name) except EntityNotFoundError as e: raise HTTPException(status_code=404, detail=str(e)) diff --git a/server/src/ledgrab/api/routes/system_settings.py b/server/src/ledgrab/api/routes/system_settings.py index a3edd9a..ad967ae 100644 --- a/server/src/ledgrab/api/routes/system_settings.py +++ b/server/src/ledgrab/api/routes/system_settings.py @@ -21,11 +21,29 @@ from ledgrab.api.schemas.system import ( ShutdownActionRequest, ShutdownActionResponse, ) +from ledgrab.core.activity_log.sanitize import sanitize_display +from ledgrab.storage.activity_log import ActivityCategory, ActivitySeverity from ledgrab.storage.database import Database from ledgrab.utils import get_logger logger = get_logger(__name__) + +def _record_setting(action: str, key: str, message: str) -> None: + """Best-effort audit record for a high-value settings change.""" + from ledgrab.core.activity_log.recorder import get_module_recorder + + rec = get_module_recorder() + if rec is not None: + rec.record( + category=ActivityCategory.SYSTEM, + action=action, + severity=ActivitySeverity.INFO, + message=message, + metadata={"setting_key": key}, + ) + + router = APIRouter() @@ -117,6 +135,11 @@ async def update_shutdown_action( """Set what happens to LED targets when the server shuts down.""" db.set_setting("shutdown_action", {"action": body.action}) logger.info("Shutdown action updated: %s", body.action) + _record_setting( + "settings.changed", + "shutdown_action", + f"Shutdown action set to '{body.action}'", + ) return ShutdownActionResponse(action=body.action) @@ -246,6 +269,17 @@ async def adb_connect(_: AuthRequired, request: AdbConnectRequest): stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=10) output = (stdout.decode() + stderr.decode()).strip() if "connected" in output.lower(): + from ledgrab.core.activity_log.recorder import get_module_recorder + + rec = get_module_recorder() + if rec is not None: + rec.record( + category=ActivityCategory.DEVICE, + action="device.adb_connected", + severity=ActivitySeverity.INFO, + message=f"ADB device connected: {sanitize_display(address)}", + metadata={"address": address}, + ) return {"status": "connected", "address": address, "message": output} raise HTTPException(status_code=400, detail=output or "Connection failed") except FileNotFoundError: @@ -276,6 +310,17 @@ async def adb_disconnect(_: AuthRequired, request: AdbConnectRequest): stderr=asyncio.subprocess.PIPE, ) stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=10) + from ledgrab.core.activity_log.recorder import get_module_recorder + + rec = get_module_recorder() + if rec is not None: + rec.record( + category=ActivityCategory.DEVICE, + action="device.adb_disconnected", + severity=ActivitySeverity.INFO, + message=f"ADB device disconnected: {sanitize_display(address)}", + metadata={"address": address}, + ) return {"status": "disconnected", "message": stdout.decode().strip()} except FileNotFoundError: raise HTTPException(status_code=500, detail="adb not found on PATH") diff --git a/server/src/ledgrab/api/routes/templates.py b/server/src/ledgrab/api/routes/templates.py index 3296d18..94fc0ac 100644 --- a/server/src/ledgrab/api/routes/templates.py +++ b/server/src/ledgrab/api/routes/templates.py @@ -183,6 +183,12 @@ async def delete_template( Validates that no streams are currently using this template before deletion. """ + _entity_name: str | None = None + try: + _entity_name = template_store.get_template(template_id).name + except Exception: + pass + try: # Check if any streams are using this template streams_using_template = [] @@ -203,7 +209,7 @@ async def delete_template( # Proceed with deletion template_store.delete_template(template_id) - fire_entity_event("capture_template", "deleted", template_id) + fire_entity_event("capture_template", "deleted", template_id, entity_name=_entity_name) except HTTPException: raise # Re-raise HTTP exceptions as-is diff --git a/server/src/ledgrab/api/routes/update.py b/server/src/ledgrab/api/routes/update.py index 4be8283..aad1221 100644 --- a/server/src/ledgrab/api/routes/update.py +++ b/server/src/ledgrab/api/routes/update.py @@ -12,6 +12,7 @@ from ledgrab.api.schemas.update import ( UpdateStatusResponse, ) from ledgrab.core.update.update_service import UpdateService +from ledgrab.storage.activity_log import ActivityCategory, ActivitySeverity from ledgrab.utils import get_logger logger = get_logger(__name__) @@ -42,6 +43,17 @@ async def dismiss_update( service: UpdateService = Depends(get_update_service), ): service.dismiss(body.version) + from ledgrab.core.activity_log.recorder import get_module_recorder + + rec = get_module_recorder() + if rec is not None: + rec.record( + category=ActivityCategory.SYSTEM, + action="update.dismissed", + severity=ActivitySeverity.INFO, + message=f"Update dismissed: {body.version}", + metadata={"version": body.version}, + ) return {"ok": True} @@ -63,6 +75,18 @@ async def apply_update( ) try: await service.apply_update() + from ledgrab.core.activity_log.recorder import get_module_recorder + + rec = get_module_recorder() + if rec is not None: + version = status.get("available_version", "unknown") + rec.record( + category=ActivityCategory.SYSTEM, + action="update.applied", + severity=ActivitySeverity.INFO, + message=f"Update applied: {version}", + metadata={"version": version}, + ) return {"ok": True, "message": "Update applied, server shutting down"} except Exception as exc: logger.error("Failed to apply update: %s", exc, exc_info=True) @@ -83,8 +107,20 @@ async def update_update_settings( body: UpdateSettingsRequest, service: UpdateService = Depends(get_update_service), ): - return await service.update_settings( + result = await service.update_settings( enabled=body.enabled, check_interval_hours=body.check_interval_hours, include_prerelease=body.include_prerelease, ) + from ledgrab.core.activity_log.recorder import get_module_recorder + + rec = get_module_recorder() + if rec is not None: + rec.record( + category=ActivityCategory.SYSTEM, + action="settings.changed", + severity=ActivitySeverity.INFO, + message=f"Update settings changed (enabled={body.enabled})", + metadata={"setting_key": "update", "enabled": body.enabled}, + ) + return result diff --git a/server/src/ledgrab/api/schemas/activity_log.py b/server/src/ledgrab/api/schemas/activity_log.py new file mode 100644 index 0000000..9bc7991 --- /dev/null +++ b/server/src/ledgrab/api/schemas/activity_log.py @@ -0,0 +1,93 @@ +"""Pydantic schemas for the activity-log API (Phase 4).""" + +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel, Field + + +# --------------------------------------------------------------------------- +# Entry + page response +# --------------------------------------------------------------------------- + + +class ActivityLogEntryResponse(BaseModel): + """Single audit-log entry. + + Shape matches ``entry_to_dict()`` from + ``ledgrab.core.activity_log.recorder`` exactly — that function is the + single source of truth for serialisation; this schema documents the wire + format. + """ + + id: str = Field(description="Entry id — 'al_<8-hex>'") + ts: str = Field(description="ISO-8601 UTC timestamp") + category: str = Field(description="Broad bucket (auth, device, entity, capture, system)") + action: str = Field(description="Verb-object label, e.g. 'entity.created'") + severity: str = Field(description="info | warning | error") + actor: str = Field(description="API-key label or 'system' / 'anonymous'") + entity_type: str | None = Field(default=None, description="Affected entity type, if applicable") + entity_id: str | None = Field(default=None, description="Affected entity id, if applicable") + entity_name: str | None = Field( + default=None, description="Entity name at time of event, if applicable" + ) + message: str = Field(description="Human-readable description") + metadata: dict[str, Any] = Field(default_factory=dict, description="Extra structured context") + + +class ActivityLogPageResponse(BaseModel): + """Paginated list of audit-log entries (keyset cursor).""" + + entries: list[ActivityLogEntryResponse] = Field(description="Entries on this page") + next_before_seq: int | None = Field( + default=None, + description=( + "Pass as 'before_seq' in the next request to get the following page. " + "None when this is the last page." + ), + ) + has_more: bool = Field( + description="True when there are more entries before the first entry on this page" + ) + total: int = Field(description="Total entries matching the current filters (all pages)") + + +# --------------------------------------------------------------------------- +# Settings +# --------------------------------------------------------------------------- + +_MAX_DAYS_CAP = 3650 # 10 years — sanity upper bound +_MAX_ENTRIES_CAP = 10_000_000 # 10 M rows — sanity upper bound + + +class ActivityLogSettingsResponse(BaseModel): + """Current activity-log retention settings.""" + + enabled: bool = Field(description="Whether the activity log is recording") + max_days: int = Field( + ge=0, + le=_MAX_DAYS_CAP, + description="Retain entries for at most this many days (0 = no age-based pruning)", + ) + max_entries: int = Field( + ge=0, + le=_MAX_ENTRIES_CAP, + description="Keep at most this many entries (0 = no count-based pruning)", + ) + + +class UpdateActivityLogSettingsRequest(BaseModel): + """Request body for PUT /settings.""" + + enabled: bool = Field(description="Enable or disable activity-log recording") + max_days: int = Field( + ge=0, + le=_MAX_DAYS_CAP, + description="Retain entries for at most this many days (0 = no age-based pruning)", + ) + max_entries: int = Field( + ge=0, + le=_MAX_ENTRIES_CAP, + description="Keep at most this many entries (0 = no count-based pruning)", + ) diff --git a/server/src/ledgrab/core/activity_log/__init__.py b/server/src/ledgrab/core/activity_log/__init__.py new file mode 100644 index 0000000..4c2b250 --- /dev/null +++ b/server/src/ledgrab/core/activity_log/__init__.py @@ -0,0 +1,25 @@ +"""Activity / audit log core — recorder, retention engine, and actor context. + +Public surface +-------------- +``ActivityRecorder`` — thread-safe facade; persists entries and fires live events. +``ActivityLogRetentionEngine`` — background pruning engine (mirrors AutoBackupEngine). +``current_actor`` — ``ContextVar[str]`` set by the auth layer per request. + +Quick start +----------- +Wired in ``main.py`` lifespan; injected via ``api/dependencies.py`` getters. +Phase 3 adds the instrumentation call sites. +""" + +from ledgrab.core.activity_log.context import current_actor +from ledgrab.core.activity_log.recorder import ActivityRecorder +from ledgrab.core.activity_log.retention import ActivityLogRetentionEngine +from ledgrab.core.activity_log.sanitize import sanitize_display + +__all__ = [ + "ActivityRecorder", + "ActivityLogRetentionEngine", + "current_actor", + "sanitize_display", +] diff --git a/server/src/ledgrab/core/activity_log/context.py b/server/src/ledgrab/core/activity_log/context.py new file mode 100644 index 0000000..657cc85 --- /dev/null +++ b/server/src/ledgrab/core/activity_log/context.py @@ -0,0 +1,21 @@ +"""Actor context variable for the activity log. + +``current_actor`` is set by ``api/auth.py:verify_api_key`` on every request so +that ``ActivityRecorder.record(...)`` can resolve the actor without requiring +every call site to pass it explicitly. + +Default value is ``"system"`` — used by background engines and any code path +that runs outside a request context (e.g. lifespan startup/shutdown, zeroconf +discovery thread). + +Per-request isolation is guaranteed by ASGI's coroutine context: each request +runs in its own coroutine with its own copy of the context inherited from the +server's main task. The auth layer resets it on every request before the route +handler runs, so stale labels from a previous request cannot bleed into a new +one. +""" + +from contextvars import ContextVar + +#: The actor label for the current request — API-key label or ``"system"``. +current_actor: ContextVar[str] = ContextVar("current_actor", default="system") diff --git a/server/src/ledgrab/core/activity_log/recorder.py b/server/src/ledgrab/core/activity_log/recorder.py new file mode 100644 index 0000000..2fc2533 --- /dev/null +++ b/server/src/ledgrab/core/activity_log/recorder.py @@ -0,0 +1,267 @@ +"""Thread-safe ActivityRecorder facade. + +Responsibilities +---------------- +1. Build an ``ActivityLogEntry`` from the caller-supplied fields. +2. Resolve the ``actor`` from the ``current_actor`` ContextVar when not given. +3. Persist the entry via ``ActivityLogRepository.record()`` on the event-loop + thread — inline if already on that thread, via + ``loop.call_soon_threadsafe`` if called from another thread (e.g. the + zeroconf discovery thread that fires ``device_discovered/lost`` events). +4. Push a live ``activity_logged`` event via + ``ProcessorManager.fire_event({"type": "activity_logged", "entry": {...}})``. +5. Never raise into the caller — audit recording is best-effort. Failures are + logged at ``WARNING`` level so operators can diagnose without breaking the + audited action. + +Thread-marshal pattern mirrors ``utils/log_broadcaster.py`` (``ensure_loop`` / +``call_soon_threadsafe``). + +Module accessor +--------------- +A module-level singleton ``_recorder`` is populated by +``set_module_recorder()`` during ``main.py`` lifespan startup and exposed via +``get_module_recorder()``. Background engines and other non-DI sites that need +to call ``record()`` without FastAPI DI can use this accessor. Phase 3 +instrumentation uses it at the ``fire_entity_event`` choke-point. +""" + +from __future__ import annotations + +import asyncio +import uuid +from datetime import datetime, timezone +from typing import TYPE_CHECKING + +from ledgrab.core.activity_log.context import current_actor +from ledgrab.storage.activity_log import ActivityLogEntry, ActivitySeverity +from ledgrab.utils import get_logger + +if TYPE_CHECKING: + from ledgrab.core.processing.processor_manager import ProcessorManager + from ledgrab.storage.activity_log_repository import ActivityLogRepository + +logger = get_logger(__name__) + + +def _new_id() -> str: + """Generate a compact activity-log entry id: ``al_<8-hex-chars>``.""" + return "al_" + uuid.uuid4().hex[:8] + + +def entry_to_dict(entry: ActivityLogEntry) -> dict: + """Serialise an ``ActivityLogEntry`` to the canonical API/event payload dict. + + Reused by Phase 4 (API response serialisation) and Phase 5 (frontend). + The shape is identical to the flat row codec minus the DB-only ``seq`` + field, but with ``ts`` kept as an ISO-8601 string and ``metadata`` as a + real ``dict`` (not a JSON string). + """ + return { + "id": entry.id, + "ts": entry.ts.isoformat(), + "category": entry.category, + "action": entry.action, + "severity": entry.severity, + "actor": entry.actor, + "entity_type": entry.entity_type, + "entity_id": entry.entity_id, + "entity_name": entry.entity_name, + "message": entry.message, + "metadata": entry.metadata, + } + + +class ActivityRecorder: + """Thread-safe facade for persisting audit log entries. + + Parameters + ---------- + repo: + ``ActivityLogRepository`` used to persist entries. + processor_manager: + ``ProcessorManager`` whose ``fire_event`` dispatches the live + ``activity_logged`` event to WebSocket subscribers. + loop: + Optional: the running asyncio event loop. If ``None``, it is + captured lazily on the first call that originates from an async + context (mirroring ``LogBroadcaster.ensure_loop``). Pass it + explicitly in tests to avoid depending on a real running loop. + """ + + def __init__( + self, + repo: "ActivityLogRepository", + processor_manager: "ProcessorManager", + *, + loop: asyncio.AbstractEventLoop | None = None, + ) -> None: + self._repo = repo + self._pm = processor_manager + self._loop: asyncio.AbstractEventLoop | None = loop + self._enabled: bool = True + + # ── Loop capture (mirrors LogBroadcaster.ensure_loop) ────────────────── + + def ensure_loop(self) -> None: + """Capture the running event loop if not already stored. + + Call from an async context (e.g. lifespan startup) so that + ``call_soon_threadsafe`` works when ``record()`` is later called from + non-async threads. + """ + if self._loop is None: + try: + self._loop = asyncio.get_running_loop() + except RuntimeError: + pass + + # ── Public API ────────────────────────────────────────────────────────── + + @property + def enabled(self) -> bool: + """Whether recording is currently active.""" + return self._enabled + + @enabled.setter + def enabled(self, value: bool) -> None: + self._enabled = value + + def record( + self, + category: str, + action: str, + *, + severity: str = ActivitySeverity.INFO, + actor: str | None = None, + entity_type: str | None = None, + entity_id: str | None = None, + entity_name: str | None = None, + message: str, + metadata: dict | None = None, + _bypass_enabled: bool = False, + ) -> None: + """Append one audit entry — best-effort, never raises. + + Parameters + ---------- + category: + Broad bucket — one of :class:`~ledgrab.storage.activity_log.ActivityCategory`. + action: + Verb-object label, e.g. ``"entity.created"`` or ``"server.shutting_down"``. + severity: + One of :class:`~ledgrab.storage.activity_log.ActivitySeverity`. Defaults + to ``"info"``. + actor: + Who triggered the action. When ``None`` (the common case), the + value is resolved from :data:`~ledgrab.core.activity_log.context.current_actor` + with a default of ``"system"``. + entity_type / entity_id / entity_name: + Optional entity context for entity-domain events. + message: + Human-readable description suitable for display. + metadata: + Small JSON-serialisable dict with extra context. Defaults to ``{}``. + _bypass_enabled: + Internal flag used by the retention engine to record the + "audit log disabled" event even when ``enabled`` is ``False``. + """ + if not self._enabled and not _bypass_enabled: + return + + # Resolve actor from ContextVar when not explicitly supplied. + resolved_actor = actor if actor is not None else current_actor.get() + + entry = ActivityLogEntry( + id=_new_id(), + ts=datetime.now(timezone.utc), + category=category, + action=action, + severity=severity, + actor=resolved_actor, + entity_type=entity_type, + entity_id=entity_id, + entity_name=entity_name, + message=message, + metadata=metadata or {}, + ) + + # Determine whether we are on the event-loop thread or not. + loop = self._loop + if loop is None: + # Lazy capture — may fail if called before the loop is running. + try: + loop = asyncio.get_running_loop() + self._loop = loop + except RuntimeError: + pass + + if loop is not None and loop.is_running(): + try: + current = asyncio.get_event_loop() + except RuntimeError: + current = None + + # If the current thread IS the event-loop thread, write inline. + if current is loop: + self._write_and_emit(entry) + else: + # Called from a non-loop thread (e.g. zeroconf discovery) — + # marshal onto the event-loop thread. + try: + loop.call_soon_threadsafe(self._write_and_emit, entry) + except RuntimeError: + # Loop has been closed (rare; happens during tests) + logger.warning( + "ActivityRecorder: event loop closed, dropping entry %s", entry.id + ) + else: + # No running loop — fall back to a direct synchronous write. + # This path hits in synchronous unit tests that do not start a loop. + self._write_and_emit(entry) + + def _write_and_emit(self, entry: ActivityLogEntry) -> None: + """Persist *entry* and fire the live event — called on the loop thread.""" + try: + self._repo.record(entry) + except Exception as exc: + logger.warning("ActivityRecorder: failed to persist entry %s: %s", entry.id, exc) + return # don't emit an event for an entry that failed to persist + + try: + self._pm.fire_event( + { + "type": "activity_logged", + "entry": entry_to_dict(entry), + } + ) + except Exception as exc: + logger.warning("ActivityRecorder: failed to fire live event for %s: %s", entry.id, exc) + + +# ── Module-level singleton accessor ──────────────────────────────────────── +# +# Background engines and non-DI call sites (Phase 3's fire_entity_event hook, +# device discovery thread) need ``record()`` without going through FastAPI DI. +# ``set_module_recorder`` is called from ``main.py`` lifespan immediately after +# the recorder is wired into ``init_dependencies``. + +_recorder: ActivityRecorder | None = None + + +def set_module_recorder(recorder: ActivityRecorder) -> None: + """Store the application-level recorder in the module singleton. + + Called once from ``main.py`` lifespan startup. + """ + global _recorder + _recorder = recorder + + +def get_module_recorder() -> ActivityRecorder | None: + """Return the module-level recorder, or ``None`` if not yet initialised. + + Callers must guard against ``None`` — this returns ``None`` during module + import and early startup before ``main.py`` lifespan has run. + """ + return _recorder diff --git a/server/src/ledgrab/core/activity_log/retention.py b/server/src/ledgrab/core/activity_log/retention.py new file mode 100644 index 0000000..e6c0fda --- /dev/null +++ b/server/src/ledgrab/core/activity_log/retention.py @@ -0,0 +1,216 @@ +"""Activity log retention engine. + +Mirrors ``core/backup/auto_backup.py``: +- Settings persisted via ``db.get_setting("activity_log")`` / + ``db.set_setting("activity_log", {...})``. +- ``start()`` / ``stop()`` lifecycle following the engine convention used + throughout the codebase. +- Hourly background loop calling ``repo.prune(before_ts=..., max_entries=...)``. +- ``get_settings()`` / ``async update_settings(...)`` for the Settings API + (Phase 4). + +Changing ``enabled`` to ``False`` records an ``"audit_log.disabled"`` event via +the recorder BEFORE the flag takes effect — so the last action in the log is a +record of the intentional disable. +""" + +from __future__ import annotations + +import asyncio +from datetime import datetime, timedelta, timezone +from typing import TYPE_CHECKING + +from ledgrab.storage.activity_log import ActivityCategory, ActivitySeverity +from ledgrab.utils import get_logger + +if TYPE_CHECKING: + from ledgrab.core.activity_log.recorder import ActivityRecorder + from ledgrab.storage.activity_log_repository import ActivityLogRepository + from ledgrab.storage.database import Database + +logger = get_logger(__name__) + +DEFAULT_SETTINGS: dict = { + "enabled": True, + "max_days": 90, + "max_entries": 20000, +} + +# Prune loop interval — run roughly once an hour. +_PRUNE_INTERVAL_SECS = 3600 + + +class ActivityLogRetentionEngine: + """Background engine that prunes old activity log entries. + + Parameters + ---------- + repo: + The ``ActivityLogRepository`` used to prune entries. + db: + The shared ``Database`` singleton for settings persistence. + recorder: + The ``ActivityRecorder`` used to log the "audit log disabled" event + before disabling takes effect. + """ + + def __init__( + self, + repo: "ActivityLogRepository", + db: "Database", + recorder: "ActivityRecorder", + ) -> None: + self._repo = repo + self._db = db + self._recorder = recorder + self._task: asyncio.Task | None = None + self._settings = self._load_settings() + # Rehydrate the recorder's enabled flag from persisted settings so a + # previously-disabled log stays disabled across restarts. + self._recorder.enabled = self._settings["enabled"] + + # ── Settings persistence ─────────────────────────────────────────────── + + def _load_settings(self) -> dict: + data = self._db.get_setting("activity_log") + if data: + return {**DEFAULT_SETTINGS, **data} + return dict(DEFAULT_SETTINGS) + + def _save_settings(self) -> None: + self._db.set_setting( + "activity_log", + { + "enabled": self._settings["enabled"], + "max_days": self._settings["max_days"], + "max_entries": self._settings["max_entries"], + }, + ) + + # ── Lifecycle ────────────────────────────────────────────────────────── + + async def start(self) -> None: + """Start the retention loop if enabled.""" + if self._settings["enabled"]: + self._start_loop() + logger.info( + "Activity log retention engine started " "(max_days=%d, max_entries=%d)", + self._settings["max_days"], + self._settings["max_entries"], + ) + else: + logger.info("Activity log retention engine initialized (disabled)") + + async def stop(self) -> None: + """Cancel the retention loop.""" + self._cancel_loop() + logger.info("Activity log retention engine stopped") + + def _start_loop(self) -> None: + self._cancel_loop() + self._task = asyncio.create_task(self._retention_loop()) + + def _cancel_loop(self) -> None: + if self._task is not None: + self._task.cancel() + self._task = None + + # ── Prune loop ───────────────────────────────────────────────────────── + + async def _retention_loop(self) -> None: + try: + while True: + await asyncio.sleep(_PRUNE_INTERVAL_SECS) + try: + self._prune() + except Exception as exc: + logger.error("Activity log retention prune failed: %s", exc, exc_info=True) + except asyncio.CancelledError: + logger.debug("Activity log retention loop cancelled") + + def _prune(self) -> None: + """Execute one prune pass based on current settings.""" + settings = self._settings + if not settings["enabled"]: + return + + max_days: int = settings["max_days"] + max_entries: int = settings["max_entries"] + + before_ts: datetime | None = None + if max_days and max_days > 0: + before_ts = datetime.now(timezone.utc) - timedelta(days=max_days) + + max_entries_val: int | None = max_entries if max_entries and max_entries > 0 else None + + deleted = self._repo.prune(before_ts=before_ts, max_entries=max_entries_val) + if deleted: + logger.info( + "Activity log pruned %d rows (max_days=%d, max_entries=%d)", + deleted, + max_days, + max_entries, + ) + + # ── Public API ───────────────────────────────────────────────────────── + + def get_settings(self) -> dict: + """Return the current retention settings dict.""" + return { + "enabled": self._settings["enabled"], + "max_days": self._settings["max_days"], + "max_entries": self._settings["max_entries"], + } + + async def update_settings( + self, + *, + enabled: bool, + max_days: int, + max_entries: int, + ) -> dict: + """Persist new settings and apply them immediately. + + If ``enabled`` is changing to ``False``, the disable event is recorded + BEFORE the flag takes effect so there is a final log entry. + + Returns the new settings dict (same as ``get_settings()``). + """ + was_enabled = self._settings["enabled"] + + # Record the disable event before the recorder stops accepting entries. + if was_enabled and not enabled: + self._recorder.record( + category=ActivityCategory.SYSTEM, + action="audit_log.disabled", + severity=ActivitySeverity.WARNING, + actor="system", + message="Activity log recording disabled via settings", + _bypass_enabled=True, + ) + + self._settings["enabled"] = enabled + self._settings["max_days"] = max_days + self._settings["max_entries"] = max_entries + self._save_settings() + + # Propagate enabled flag to the recorder. + self._recorder.enabled = enabled + + if enabled: + self._start_loop() + logger.info( + "Activity log retention enabled (max_days=%d, max_entries=%d)", + max_days, + max_entries, + ) + # Run an immediate prune pass when re-enabling. + try: + self._prune() + except Exception as exc: + logger.error("Activity log immediate prune failed: %s", exc) + else: + self._cancel_loop() + logger.info("Activity log retention disabled") + + return self.get_settings() diff --git a/server/src/ledgrab/core/activity_log/sanitize.py b/server/src/ledgrab/core/activity_log/sanitize.py new file mode 100644 index 0000000..e454a17 --- /dev/null +++ b/server/src/ledgrab/core/activity_log/sanitize.py @@ -0,0 +1,82 @@ +"""Log-injection sanitizer for audit-log message and display strings. + +Provides :func:`sanitize_display` — a dependency-free helper that strips +characters that should not appear in a recorded ``message`` or display +string before it is persisted to SQLite, broadcast over WebSocket, or +exported to CSV. + +Design constraints +------------------ +- **Dependency-free**: uses only the Python standard library so it can be + imported from any module without adding transitive weight. +- **Conservative**: keeps printable ASCII/Unicode and normal spaces; drops + everything else including control chars (NUL, BEL, BS, VT, FF, ESC, + DEL), ANSI/CSI escape sequences (``\\x1b[...``), and carriage returns / + newlines / tabs which are the classic log-injection primitives. +- **Length-capped**: truncates to *maxlen* characters and appends ``"…"`` + so callers can rely on a bounded string without adding their own guards. +""" + +from __future__ import annotations + +import re + +# Matches ANSI/VT100 escape sequences: ESC [ ... m (CSI) and shorter forms. +# We strip these before the printable-char filter so the bracket/letters that +# follow the ESC don't survive stripping the ESC alone. +_ANSI_RE = re.compile(r"\x1b(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])") + +# Characters we explicitly want to remove even if str.isprintable() would +# let them through in some edge-case: NUL is the canonical SQL/log null-byte +# injection; the others are kept out by the printable check but listed here +# for documentation clarity. +_EXPLICIT_DROP = frozenset("\x00\r\n\t") + + +def sanitize_display(value: str | None, *, maxlen: int = 120) -> str: + """Return a sanitized, length-capped version of *value* safe for log messages. + + Parameters + ---------- + value: + The raw, potentially attacker-controlled string. ``None`` or empty + returns ``""``. + maxlen: + Maximum length of the returned string (default: 120). If the input + exceeds this length after sanitization, the string is truncated and + ``"…"`` is appended (the ellipsis counts toward *maxlen*). + + Returns + ------- + str + A string that: + - contains no NUL bytes (``\\x00``), + - contains no ANSI/CSI escape sequences, + - contains no carriage returns, newlines, or tab characters, + - contains only characters for which ``str.isprintable()`` is ``True`` + plus the regular ASCII space (``\\x20``), + - is at most *maxlen* characters long. + """ + if not value: + return "" + + # 1. Strip ANSI escape sequences first so their bracket/letter tails don't + # survive as stray printable characters. + cleaned = _ANSI_RE.sub("", value) + + # 2. Drop each character that is neither printable nor a plain space. + # str.isprintable() returns False for all control chars (including NUL, + # BEL, BS, TAB, LF, VT, FF, CR, ESC, DEL) and True for normal letters, + # digits, punctuation, and the space character. + cleaned = "".join(ch for ch in cleaned if ch.isprintable() or ch == " ") + + # 3. Final belt-and-suspenders pass for the explicit drop set (catches NUL + # that may survive if isprintable ever changes in a future Python version). + cleaned = "".join(ch for ch in cleaned if ch not in _EXPLICIT_DROP) + + # 4. Cap length. + if len(cleaned) > maxlen: + # Reserve one character for the ellipsis so total length == maxlen. + cleaned = cleaned[: maxlen - 1] + "…" + + return cleaned diff --git a/server/src/ledgrab/core/automations/automation_engine.py b/server/src/ledgrab/core/automations/automation_engine.py index 257cc42..d4189b5 100644 --- a/server/src/ledgrab/core/automations/automation_engine.py +++ b/server/src/ledgrab/core/automations/automation_engine.py @@ -726,6 +726,28 @@ class AutomationEngine: else: logger.info(f"Automation '{automation.name}' activated (scene '{preset.name}' applied)") + # Audit record — best-effort. + try: + from ledgrab.core.activity_log.recorder import get_module_recorder + from ledgrab.core.activity_log.sanitize import sanitize_display + from ledgrab.storage.activity_log import ActivityCategory, ActivitySeverity + + rec = get_module_recorder() + if rec is not None: + _safe_name = sanitize_display(automation.name) if automation.name else None + rec.record( + category=ActivityCategory.CAPTURE, + action="automation.activated", + severity=ActivitySeverity.INFO, + actor="system", + entity_type="automation", + entity_id=automation.id, + entity_name=_safe_name, + message=f"Automation '{_safe_name or automation.id}' activated", + ) + except Exception: + pass + async def _deactivate_automation(self, automation_id: str) -> None: was_active = self._active_automations.pop(automation_id, False) if not was_active: @@ -751,6 +773,33 @@ class AutomationEngine: # Clean up any leftover snapshot self._pre_activation_snapshots.pop(automation_id, None) + # Audit record — best-effort. + try: + from ledgrab.core.activity_log.recorder import get_module_recorder + from ledgrab.core.activity_log.sanitize import sanitize_display + from ledgrab.storage.activity_log import ActivityCategory, ActivitySeverity + + rec = get_module_recorder() + if rec is not None: + _auto_name: str | None = None + try: + _auto_name = self._store.get_automation(automation_id).name + except Exception: + pass + _safe_deact_name = sanitize_display(_auto_name) if _auto_name else None + rec.record( + category=ActivityCategory.CAPTURE, + action="automation.deactivated", + severity=ActivitySeverity.INFO, + actor="system", + entity_type="automation", + entity_id=automation_id, + entity_name=_safe_deact_name, + message=f"Automation '{_safe_deact_name or automation_id}' deactivated", + ) + except Exception: + pass + async def _deactivate_revert(self, automation_id: str) -> None: """Revert to pre-activation snapshot.""" snapshot = self._pre_activation_snapshots.pop(automation_id, None) diff --git a/server/src/ledgrab/core/capture/calibration.py b/server/src/ledgrab/core/capture/calibration.py index 2d3af4c..b29c112 100644 --- a/server/src/ledgrab/core/capture/calibration.py +++ b/server/src/ledgrab/core/capture/calibration.py @@ -824,6 +824,30 @@ def create_default_calibration( right_count = max(1, right_count) left_count = max(1, left_count) + # The max(1, ...) floors above can push the total above led_count for + # small counts (e.g. led_count=5 -> top=2,right=1,bottom=2,left=1 = 6). + # Trim the largest edge that stays >= 1 until the total matches exactly. + edge_order = ["bottom", "top", "right", "left"] + counts = { + "bottom": bottom_count, + "top": top_count, + "right": right_count, + "left": left_count, + } + overshoot = sum(counts.values()) - led_count + while overshoot > 0: + # Pick the largest edge that can still be reduced (stays >= 1). + trimmable = [e for e in edge_order if counts[e] > 1] + if not trimmable: + break + target_edge = max(trimmable, key=lambda e: counts[e]) + counts[target_edge] -= 1 + overshoot -= 1 + bottom_count = counts["bottom"] + top_count = counts["top"] + right_count = counts["right"] + left_count = counts["left"] + config = CalibrationConfig( layout="clockwise", start_position="bottom_left", diff --git a/server/src/ledgrab/core/devices/ddp_client.py b/server/src/ledgrab/core/devices/ddp_client.py index 7c3ac41..d93e6af 100644 --- a/server/src/ledgrab/core/devices/ddp_client.py +++ b/server/src/ledgrab/core/devices/ddp_client.py @@ -154,6 +154,15 @@ class DDPClient: all buses (observed in multi-bus setups). We reorder pixel channels here so the hardware receives the correct byte order directly. + TODO(ddp-multibus): currently UNUSED — ``send_pixels_numpy`` (the hot + send path) does NOT call this, so the per-bus color-order config + captured by ``set_buses`` is never applied to outgoing pixels. This + is intentionally left in place (not deleted) because it encodes real + multi-bus handling: if a multi-bus WLED setup needs per-bus byte + reordering, wire this into ``send_pixels_numpy`` before the payload + view is built (note it allocates a copy, so only call it when + ``self._buses`` actually requires reordering). + Args: pixel_array: (N, 3) uint8 numpy array in RGB order diff --git a/server/src/ledgrab/core/devices/discovery_watcher.py b/server/src/ledgrab/core/devices/discovery_watcher.py index 24a86f1..fd5c02a 100644 --- a/server/src/ledgrab/core/devices/discovery_watcher.py +++ b/server/src/ledgrab/core/devices/discovery_watcher.py @@ -36,6 +36,7 @@ from zeroconf.asyncio import AsyncServiceBrowser, AsyncServiceInfo, AsyncZerocon from ledgrab.core.devices.serial_transport import list_serial_ports from ledgrab.core.devices.wled_provider import WLED_MDNS_TYPE +from ledgrab.storage.activity_log import ActivityCategory, ActivitySeverity from ledgrab.utils import get_logger from ledgrab.utils.platform import is_android @@ -286,3 +287,34 @@ class DiscoveryWatcher: ) except Exception as e: logger.debug("Discovery watcher: fire_event failed: %s", e) + + # Audit record — best-effort, thread-safe (recorder marshals via + # call_soon_threadsafe when called from the zeroconf thread). + try: + from ledgrab.core.activity_log.recorder import get_module_recorder + from ledgrab.core.activity_log.sanitize import sanitize_display + + rec = get_module_recorder() + if rec is not None: + is_discovered = event_type == "device_discovered" + action = "device.discovered" if is_discovered else "device.lost" + severity = ActivitySeverity.INFO if is_discovered else ActivitySeverity.WARNING + verb = "discovered" if is_discovered else "lost" + # Sanitize mDNS-advertised strings before they enter the log. + # entry.name and entry.url are unauthenticated, attacker-controlled + # values; strip control chars, ANSI escapes, and NUL before use. + safe_name = sanitize_display(entry.name) + safe_url = sanitize_display(entry.url) + rec.record( + category=ActivityCategory.DEVICE, + action=action, + severity=severity, + actor="system", + entity_type="device", + entity_id=entry.url, + entity_name=safe_name, + message=f"Device '{safe_name}' {verb} at {safe_url}", + metadata={"url": safe_url, "device_type": entry.device_type}, + ) + except Exception as e: + logger.debug("Discovery watcher: audit record failed: %s", e) diff --git a/server/src/ledgrab/core/game_integration/adapters/cs2_adapter.py b/server/src/ledgrab/core/game_integration/adapters/cs2_adapter.py index 4355b38..a63f54e 100644 --- a/server/src/ledgrab/core/game_integration/adapters/cs2_adapter.py +++ b/server/src/ledgrab/core/game_integration/adapters/cs2_adapter.py @@ -260,7 +260,10 @@ class CS2Adapter(GameAdapter): auth_section = payload.get("auth", {}) actual_token = auth_section.get("token", "") - return bool(actual_token and actual_token == expected_token) + if not actual_token: + return False + # Constant-time comparison to avoid a timing oracle. + return secrets.compare_digest(actual_token, expected_token) @classmethod def get_config_schema(cls) -> dict[str, Any]: diff --git a/server/src/ledgrab/core/game_integration/adapters/dota2_adapter.py b/server/src/ledgrab/core/game_integration/adapters/dota2_adapter.py index 0c1a1ba..35a170e 100644 --- a/server/src/ledgrab/core/game_integration/adapters/dota2_adapter.py +++ b/server/src/ledgrab/core/game_integration/adapters/dota2_adapter.py @@ -177,7 +177,10 @@ class Dota2Adapter(GameAdapter): auth_section = payload.get("auth", {}) actual_token = auth_section.get("token", "") - return bool(actual_token and actual_token == expected_token) + if not actual_token: + return False + # Constant-time comparison to avoid a timing oracle. + return secrets.compare_digest(actual_token, expected_token) @classmethod def get_config_schema(cls) -> dict[str, Any]: diff --git a/server/src/ledgrab/core/game_integration/adapters/generic_webhook_adapter.py b/server/src/ledgrab/core/game_integration/adapters/generic_webhook_adapter.py index c699d3e..f875773 100644 --- a/server/src/ledgrab/core/game_integration/adapters/generic_webhook_adapter.py +++ b/server/src/ledgrab/core/game_integration/adapters/generic_webhook_adapter.py @@ -4,6 +4,7 @@ Allows users to define custom JSON path mappings via the adapter_config rather than a YAML file. Delegates all parsing logic to MappingAdapter. """ +import secrets from typing import Any, ClassVar from ledgrab.core.game_integration.base_adapter import GameAdapter @@ -54,11 +55,18 @@ class GenericWebhookAdapter(GameAdapter): payload: dict[str, Any], adapter_config: dict[str, Any], ) -> bool: - """Validate auth using a configurable header token.""" + """Validate auth using a configurable header token. + + Secure-by-default: this adapter is explicitly network-facing + (it accepts unauthenticated HTTP POSTs from anywhere on the LAN), + so a missing/empty ``auth_token`` REJECTS the request rather than + accepting it. A token must be configured for ingestion to work. + """ expected_token = adapter_config.get("auth_token") if not expected_token: - # No auth configured - return True + # No token configured — reject (secure-by-default for a + # network-facing adapter; an open webhook is a LAN attack surface). + return False auth_header = adapter_config.get("auth_header", "Authorization") actual_value = headers.get(auth_header, "") @@ -67,18 +75,27 @@ class GenericWebhookAdapter(GameAdapter): if actual_value.startswith("Bearer "): actual_value = actual_value[7:] - return bool(actual_value and actual_value == expected_token) + if not actual_value: + return False + + # Constant-time comparison to avoid a token-length/timing oracle. + return secrets.compare_digest(actual_value, expected_token) @classmethod def get_config_schema(cls) -> dict[str, Any]: """Return generic webhook config schema.""" return { "type": "object", + "required": ["auth_token"], "properties": { "auth_token": { "type": "string", "title": "Auth Token", - "description": "Optional token for authenticating incoming webhooks.", + "description": ( + "Required token for authenticating incoming webhooks. " + "Without it, ingestion is rejected (this adapter is " + "network-facing and secure-by-default)." + ), }, "auth_header": { "type": "string", @@ -136,15 +153,17 @@ class GenericWebhookAdapter(GameAdapter): "HTTP POST requests with JSON payloads.\n\n" "**Steps:**\n" "1. Configure your event mappings above — map JSON paths to standard events\n" - "2. Set an auth token (optional but recommended)\n" + "2. Set an auth token (REQUIRED — without it incoming webhooks are rejected)\n" "3. Point your game/application to:\n" " `POST http://:8080/api/v1/game-integrations//event`\n\n" "**Mapping example:**\n" "- Source path: `player.stats.health` → Event: `health` (min: 0, max: 100)\n" "- Source path: `events.kill_count` → Event: `kill` (trigger: on_increase)\n\n" - "**Auth:**\n" + "**Auth (required):**\n" "- Set `Authorization: Bearer ` header in your webhook sender\n" "- Or configure a custom auth header name in the adapter config\n" + "- A token is mandatory: this endpoint is reachable from the LAN, so\n" + " an unconfigured token rejects all incoming events.\n" ) diff --git a/server/src/ledgrab/core/game_integration/mapping_adapter.py b/server/src/ledgrab/core/game_integration/mapping_adapter.py index d59a2e6..808daaa 100644 --- a/server/src/ledgrab/core/game_integration/mapping_adapter.py +++ b/server/src/ledgrab/core/game_integration/mapping_adapter.py @@ -10,6 +10,7 @@ The MappingAdapter class is a concrete GameAdapter whose behavior is entirely driven by the parsed YAML definition. """ +import secrets import time from pathlib import Path from typing import Any @@ -241,7 +242,10 @@ class MappingAdapter(GameAdapter): expected_key = "auth_token" expected_value = adapter_config.get(expected_key, "") actual_value = headers.get(header_name, "") - return bool(expected_value and actual_value == expected_value) + if not (expected_value and actual_value): + return False + # Constant-time comparison to avoid a timing oracle. + return secrets.compare_digest(actual_value, expected_value) logger.warning(f"Unknown auth type '{auth_type}' in mapping adapter '{self._name}'") return False diff --git a/server/src/ledgrab/core/processing/composite_stream.py b/server/src/ledgrab/core/processing/composite_stream.py index e3a5c9d..1f53b6d 100644 --- a/server/src/ledgrab/core/processing/composite_stream.py +++ b/server/src/ledgrab/core/processing/composite_stream.py @@ -69,6 +69,9 @@ class CompositeColorStripStream(ColorStripStream): # (src_len, dst_len) -> (src_x, dst_x, buffer) cache for zone resizing self._resize_cache: Dict[tuple, tuple] = {} + # (src_len, target_n) -> (src_x, dst_x) cache for full-strip resizing + # (output reuses the preallocated self._resize_buf from _ensure_pool) + self._resize_linspace_cache: Dict[tuple, tuple] = {} # layer_index -> (source_id, consumer_id, stream) self._sub_streams: Dict[int, tuple] = {} # layer_index -> (vs_id, value_stream) @@ -314,8 +317,14 @@ class CompositeColorStripStream(ColorStripStream): n_src = len(colors) if n_src == target_n: return colors - src_x = np.linspace(0, 1, n_src) - dst_x = np.linspace(0, 1, target_n) + # Cache the (src_x, dst_x) linspace arrays keyed by (n_src, target_n) + # exactly like the zone path, so they are not reallocated every frame. + lkey = (n_src, target_n) + linspaces = self._resize_linspace_cache.get(lkey) + if linspaces is None: + linspaces = (np.linspace(0, 1, n_src), np.linspace(0, 1, target_n)) + self._resize_linspace_cache[lkey] = linspaces + src_x, dst_x = linspaces buf = self._resize_buf for ch in range(3): np.copyto( diff --git a/server/src/ledgrab/core/processing/device_health.py b/server/src/ledgrab/core/processing/device_health.py index aa37aaa..2eaef07 100644 --- a/server/src/ledgrab/core/processing/device_health.py +++ b/server/src/ledgrab/core/processing/device_health.py @@ -11,6 +11,8 @@ from ledgrab.core.devices.led_client import ( check_device_health, get_device_capabilities, ) +from ledgrab.core.activity_log.sanitize import sanitize_display +from ledgrab.storage.activity_log import ActivityCategory, ActivitySeverity from ledgrab.utils import get_logger logger = get_logger(__name__) @@ -128,6 +130,35 @@ class DeviceHealthMixin: "latency_ms": state.health.latency_ms, } ) + # Audit record for device online/offline transition. + from ledgrab.core.activity_log.recorder import get_module_recorder + + rec = get_module_recorder() + if rec is not None: + is_online = state.health.online + # Best-effort name lookup from the device store. + device_name: str | None = None + try: + if self._device_store is not None: + device_name = self._device_store.get_device(device_id).name + except Exception: + pass + safe_name = sanitize_display(device_name) if device_name else None + display = safe_name or device_id + action = "device.online" if is_online else "device.offline" + severity = ActivitySeverity.INFO if is_online else ActivitySeverity.WARNING + status_word = "came online" if is_online else "went offline" + rec.record( + category=ActivityCategory.DEVICE, + action=action, + severity=severity, + actor="system", + entity_type="device", + entity_id=device_id, + entity_name=safe_name, + message=f"Device '{display}' {status_word}", + metadata={"latency_ms": state.health.latency_ms}, + ) # Auto-sync LED count reported = state.health.device_led_count diff --git a/server/src/ledgrab/core/processing/device_test_mode.py b/server/src/ledgrab/core/processing/device_test_mode.py index 7a8227f..aa15226 100644 --- a/server/src/ledgrab/core/processing/device_test_mode.py +++ b/server/src/ledgrab/core/processing/device_test_mode.py @@ -137,16 +137,34 @@ class DeviceTestModeMixin: await self._send_pixels_to_device(device_id, pixels) async def _send_clear_pixels(self, device_id: str) -> None: - """Send all-black pixels to clear LED output.""" + """Send all-black pixels to clear LED output. + + This is the explicit teardown path — unlike the per-frame + ``_send_pixels_to_device`` swallow, a clear that must actually take + effect retries once before giving up, so a single transient send + error doesn't leave the device lit after a session ends. + """ ds = self._devices[device_id] pixels = [(0, 0, 0)] * ds.led_count - await self._send_pixels_to_device(device_id, pixels) + try: + client = await self._get_idle_client(device_id) + await client.send_pixels(pixels) + except Exception as e: + logger.warning(f"Clear send to {device_id} failed, retrying once: {e}") + client = await self._get_idle_client(device_id) + await client.send_pixels(pixels) async def _send_pixels_to_device(self, device_id: str, pixels) -> None: """Send pixels to a device via cached idle client. Reuses a cached connection to avoid repeated serial reconnections (which trigger Arduino bootloader reset on Adalight devices). + + Send failures are logged and swallowed (best-effort per-frame send). + Callers that need a *guaranteed* clear — e.g. session teardown that + must "never leave the device dark" — must NOT rely on this returning + cleanly; use ``_send_clear_pixels`` (which retries once) and treat a + propagated exception as a failed clear. """ try: client = await self._get_idle_client(device_id) diff --git a/server/src/ledgrab/core/processing/effect_stream.py b/server/src/ledgrab/core/processing/effect_stream.py index 4b98069..e63b61c 100644 --- a/server/src/ledgrab/core/processing/effect_stream.py +++ b/server/src/ledgrab/core/processing/effect_stream.py @@ -973,13 +973,18 @@ class EffectColorStripStream(ColorStripStream): # Use noise at very low frequency for blob movement np.multiply(self._s_arange, scale * 0.03, out=self._s_f32_a) - # Two blob layers at different speeds for organic movement + # Two blob layers at different speeds for organic movement. + # fbm() returns a shared internal buffer that the next fbm() call + # overwrites, so each layer must be copied out — write into the + # preallocated scratch buffers instead of allocating per frame. self._s_f32_a += t * speed * 0.1 - layer1 = self._noise.fbm(self._s_f32_a, octaves=3).copy() + layer1 = self._s_layer1 + np.copyto(layer1, self._noise.fbm(self._s_f32_a, octaves=3)) np.multiply(self._s_arange, scale * 0.05, out=self._s_f32_a) self._s_f32_a += t * speed * 0.07 + 100.0 - layer2 = self._noise.fbm(self._s_f32_a, octaves=2).copy() + layer2 = self._s_layer2 + np.copyto(layer2, self._noise.fbm(self._s_f32_a, octaves=2)) # Combine: create blob-like shapes with soft edges combined = self._s_f32_a diff --git a/server/src/ledgrab/core/processing/wled_target_processor.py b/server/src/ledgrab/core/processing/wled_target_processor.py index b820522..6bd0a59 100644 --- a/server/src/ledgrab/core/processing/wled_target_processor.py +++ b/server/src/ledgrab/core/processing/wled_target_processor.py @@ -739,10 +739,19 @@ class WledTargetProcessor(TargetProcessor): self._last_preview_data = data - async def _send_safe(ws): + # Bound each per-client send to roughly one frame interval so a slow or + # backpressured preview WebSocket can never throttle the device send + # cadence. Clients that time out or error are dropped from the set. + eff_fps = self._effective_fps if self._effective_fps > 0 else 30 + send_timeout = 1.0 / eff_fps + + async def _send_safe(ws) -> bool: try: - await ws.send_bytes(data) + await asyncio.wait_for(ws.send_bytes(data), timeout=send_timeout) return True + except asyncio.TimeoutError: + logger.debug("LED preview broadcast WS send timed out (slow client dropped)") + return False except Exception as e: logger.debug("LED preview broadcast WS send failed: %s", e) return False diff --git a/server/src/ledgrab/core/scenes/playlist_engine.py b/server/src/ledgrab/core/scenes/playlist_engine.py index 71a0763..1a401ef 100644 --- a/server/src/ledgrab/core/scenes/playlist_engine.py +++ b/server/src/ledgrab/core/scenes/playlist_engine.py @@ -138,22 +138,42 @@ class PlaylistEngine: """Stop the playlist only if ``playlist_id`` is the one running. Used when a playlist is deleted or edited so a stale snapshot can't keep - cycling. + cycling. The read-compare and the stop happen atomically under the + lifecycle lock so a concurrent natural-end / start can't slip a + different playlist in between the check and the stop. """ - if self._state is not None and self._state.playlist_id == playlist_id: - await self.stop() + async with self._lifecycle_lock: + state = self._state + if state is None or state.playlist_id != playlist_id: + return + was_running = self._task is not None + await self._cancel_task() + stopped_id = self._state.playlist_id if self._state else playlist_id + self._state = None + if was_running: + self._fire_event("stopped", playlist_id=stopped_id) + logger.info("Playlist stopped") # ===== Query API (used by routes) ===== def is_running(self) -> bool: - return self._task is not None and not self._task.done() + # Snapshot the task ref so a concurrent clear (set to None at an await + # boundary) can't turn the deref into an attribute error mid-read. + task = self._task + return task is not None and not task.done() def get_running_playlist_id(self) -> str | None: - return self._state.playlist_id if self._state else None + state = self._state + return state.playlist_id if state else None def get_state(self) -> dict: - if self._state is not None and self.is_running(): - return self._state.to_dict() + # Snapshot both refs once: the event loop won't preempt this sync method + # between the reads, but snapshotting also guards against ever returning + # a half-cleared state (running True while _state is already None). + state = self._state + task = self._task + if state is not None and task is not None and not task.done(): + return state.to_dict() return dict(_IDLE_STATE) # ===== Internal ===== @@ -201,13 +221,25 @@ class PlaylistEngine: break # Natural end (non-loop or guard). Clear state without recursing - # through stop() (which would try to cancel this very task). Guard - # against a concurrent start_playlist having already replaced us: - # only clear if we are still the engine's current task. - if self._task is asyncio.current_task(): - self._task = None - ended_id = self._state.playlist_id if self._state else None - self._state = None + # through stop() (which would try to cancel this very task). Take + # the lifecycle lock so the clear + 'stopped' event are atomic with + # respect to a concurrent start/stop/stop_if_running — otherwise the + # two could interleave and emit duplicate or contradictory terminal + # events. _run never calls _cancel_task and never otherwise holds + # this lock, so acquiring it here cannot deadlock; if a canceller + # holding the lock cancels us while we wait to acquire it, the + # acquire raises CancelledError and we fall through to the handler. + ended_id = None + should_fire = False + async with self._lifecycle_lock: + # Re-check under the lock: a concurrent start_playlist may have + # replaced us while we waited. Only clear if we're still current. + if self._task is asyncio.current_task(): + self._task = None + ended_id = self._state.playlist_id if self._state else None + self._state = None + should_fire = True + if should_fire: self._fire_event("stopped", playlist_id=ended_id) logger.info("Playlist '%s' finished", playlist_id) except asyncio.CancelledError: diff --git a/server/src/ledgrab/main.py b/server/src/ledgrab/main.py index a28402b..a43ee0f 100644 --- a/server/src/ledgrab/main.py +++ b/server/src/ledgrab/main.py @@ -60,6 +60,9 @@ from ledgrab.storage.audio_processing_template_store import AudioProcessingTempl from ledgrab.storage.pattern_template_store import PatternTemplateStore import ledgrab.core.audio.filters # noqa: F401 — trigger audio filter auto-registration from ledgrab.core.backup.auto_backup import AutoBackupEngine +from ledgrab.core.activity_log.recorder import ActivityRecorder, set_module_recorder +from ledgrab.core.activity_log.retention import ActivityLogRetentionEngine +from ledgrab.storage.activity_log_repository import ActivityLogRepository from ledgrab.core.processing.os_notification_listener import OsNotificationListener from ledgrab.core.devices.discovery_watcher import DiscoveryWatcher from ledgrab.core.update.update_service import UpdateService @@ -184,6 +187,10 @@ pattern_template_store = PatternTemplateStore(db) game_event_bus = GameEventBus() register_community_adapters() +# Activity log repository — constructed at module level like other stores so +# it migrates the DB schema (``002_add_activity_log``) on import. +activity_log_repo = ActivityLogRepository(db) + processor_manager = ProcessorManager( ProcessorDependencies( picture_source_store=picture_source_store, @@ -290,6 +297,17 @@ async def lifespan(app: FastAPI): processor_manager=processor_manager, ) + # Create activity recorder + retention engine. The recorder needs the + # processor_manager to fire live events, so it is built after that is + # already constructed at module level. + activity_recorder = ActivityRecorder(activity_log_repo, processor_manager) + activity_recorder.ensure_loop() + activity_log_retention_engine = ActivityLogRetentionEngine( + repo=activity_log_repo, + db=db, + recorder=activity_recorder, + ) + # Create auto-backup engine — derive paths from database location so that # demo mode auto-backups go to data/demo/ instead of data/. _data_dir = Path(config.storage.database_file).parent @@ -347,7 +365,13 @@ async def lifespan(app: FastAPI): http_endpoint_store=http_endpoint_store, audio_processing_template_store=audio_processing_template_store, pattern_template_store=pattern_template_store, + activity_recorder=activity_recorder, + activity_log_repo=activity_log_repo, + activity_log_retention_engine=activity_log_retention_engine, ) + # Expose the recorder via the module singleton so non-DI sites + # (fire_entity_event, device threads) can call record() without FastAPI DI. + set_module_recorder(activity_recorder) # Register devices in processor manager for health monitoring devices = device_store.get_all_devices() @@ -390,6 +414,9 @@ async def lifespan(app: FastAPI): # Start auto-backup engine (periodic configuration backups) await auto_backup_engine.start() + # Start activity log retention engine (hourly prune of old entries) + await activity_log_retention_engine.start() + # Start update checker (periodic release polling) await update_service.start() @@ -438,6 +465,19 @@ async def lifespan(app: FastAPI): except Exception as e: logger.error("Shutdown step '%s' raised: %s", label, e) + # Record the shutdown event FIRST — before any engine teardown — so there + # is always a final log entry on graceful shutdown. + try: + activity_recorder.record( + category="system", + action="server.shutting_down", + severity="info", + actor="system", + message="Server is shutting down", + ) + except Exception as e: + logger.error("Failed to record shutdown event: %s", e) + # Legacy hook — SQLite stores are write-through so this only logs. # Durability comes from PRAGMA synchronous=FULL + the explicit # wal_checkpoint(TRUNCATE) in Database.close() at the end of this block. @@ -453,6 +493,13 @@ async def lifespan(app: FastAPI): # Stop the playlist engine so its cycling task can't apply scenes mid-shutdown. await _bounded("playlist_engine.stop", playlist_engine.stop(), timeout=1.0) + # Tear down any active calibration session BEFORE stop_all so the device + # isn't left stuck in the white-chase and its prior target is restored. + # stop() is a no-op when no session is active. + from ledgrab.core.capture.calibration_session import get_calibration_session + + await _bounded("calibration_session.stop", get_calibration_session().stop(), timeout=1.0) + # Stop discovery watcher and OS notification listener so they stop # firing events into a shutting-down processor manager. if discovery_watcher is not None: @@ -503,6 +550,7 @@ async def lifespan(app: FastAPI): await _bounded("update_service.stop", update_service.stop(), timeout=0.5) await _bounded("auto_backup_engine.stop", auto_backup_engine.stop(), timeout=0.5) + await _bounded("activity_log_retention.stop", activity_log_retention_engine.stop(), timeout=0.5) # Close the DB last so it runs a TRUNCATE checkpoint, flushing the WAL # into the main file. Without this, writes can survive a graceful app diff --git a/server/src/ledgrab/static/css/activity-log.css b/server/src/ledgrab/static/css/activity-log.css new file mode 100644 index 0000000..d78e715 --- /dev/null +++ b/server/src/ledgrab/static/css/activity-log.css @@ -0,0 +1,770 @@ +/* ───────────────────────────────────────────────────────────────────────── + Activity Log — audit viewer tab + Design language: precision-instrument / ledger. Monospaced timestamps, + color-coded severity rail, thin category pills. Clean "terminal" feel + without being cold — the primary green accent anchors the live-update dot. + ───────────────────────────────────────────────────────────────────────── */ + +/* ── Panel wrapper ───────────────────────────────────────────────────────── */ + +.al-panel { + display: flex; + flex-direction: column; + gap: 0; + max-width: 1400px; + margin: 0 auto; + padding: var(--space-lg) var(--space-lg) var(--space-xl); + min-height: 0; +} + +/* ── Filter toolbar ──────────────────────────────────────────────────────── */ + +.al-toolbar { + display: flex; + flex-direction: column; + gap: var(--space-sm); + padding: var(--space-md) var(--space-md); + /* Match the elevated card surface used by entity cards (.dashboard-target), + not the near-black --bg-secondary, so the panel reads as one of the app's + cards rather than a separate flat sheet. */ + background: var(--lux-bg-1, var(--card-bg)); + border: var(--lux-hairline) solid var(--lux-line, var(--border-color)); + border-radius: var(--radius-md); + margin-bottom: var(--space-md); +} + +.al-toolbar-row { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--space-sm); +} + +.al-toolbar-search { + gap: var(--space-sm); +} + +/* Search input */ +.al-search-wrap { + position: relative; + flex: 1; + min-width: 200px; + max-width: 420px; +} + +.al-search-icon { + position: absolute; + left: 10px; + top: 50%; + transform: translateY(-50%); + pointer-events: none; + color: var(--text-muted); + display: flex; + align-items: center; +} + +.al-search-icon .icon { + width: 15px; + height: 15px; +} + +.al-search-input { + width: 100%; + padding: 6px 10px 6px 34px; + background: var(--card-bg); + border: var(--lux-hairline) solid var(--border-color); + border-radius: var(--radius-sm); + color: var(--text-color); + font-size: 0.875rem; + font-family: var(--font-body); + transition: border-color var(--duration-fast) var(--ease-out), + box-shadow var(--duration-fast) var(--ease-out); + outline: none; +} + +.al-search-input:focus { + border-color: var(--primary-color); + box-shadow: 0 0 0 2px rgba(76, 175, 80, 0.15); +} + +.al-search-input::placeholder { color: var(--text-muted); } + +/* Quick presets */ +.al-presets { + display: flex; + gap: 4px; + flex-wrap: wrap; +} + +.al-preset-btn { + padding: 4px 10px; + font-size: 0.75rem; + font-weight: 500; + background: var(--card-bg); + border: var(--lux-hairline) solid var(--border-color); + border-radius: var(--radius-pill); + color: var(--text-secondary); + cursor: pointer; + transition: background var(--duration-fast), color var(--duration-fast), border-color var(--duration-fast); + white-space: nowrap; +} + +.al-preset-btn:hover { + background: var(--primary-color); + border-color: var(--primary-color); + color: var(--primary-contrast); +} + +/* Clear button */ +.al-clear-btn { + padding: 4px 6px; + margin-left: auto; + color: var(--text-secondary); +} + +.al-clear-btn:hover { color: var(--danger-color); border-color: var(--danger-color); } + +/* Export button + dropdown */ +.al-export-wrap { + position: relative; + margin-left: auto; +} + +.al-export-btn { + display: flex; + align-items: center; + gap: 5px; + padding: 5px 12px; + font-size: 0.8125rem; +} + +.al-export-btn .icon { width: 14px; height: 14px; } + +/* Caret signals this button opens a menu (rather than firing a direct action), + and rotates to point up while the menu is open. */ +.al-export-caret { + display: inline-flex; + margin-left: 1px; + transition: transform var(--duration-fast) var(--ease-out); +} +.al-export-caret .icon { width: 12px; height: 12px; } +.al-export-wrap.open .al-export-caret { transform: rotate(180deg); } + +.al-export-menu { + display: none; + position: absolute; + right: 0; + top: calc(100% + 4px); + background: var(--card-bg); + border: var(--lux-hairline) solid var(--border-color); + border-radius: var(--radius-sm); + box-shadow: 0 4px 12px var(--shadow-color); + z-index: 100; + min-width: 140px; + overflow: hidden; +} + +.al-export-wrap.open .al-export-menu { display: block; } + +.al-export-menu button { + display: block; + width: 100%; + padding: 8px 14px; + text-align: left; + background: none; + border: none; + color: var(--text-color); + font-size: 0.8125rem; + cursor: pointer; +} + +.al-export-menu button:hover, +.al-export-menu button:focus-visible { background: var(--bg-secondary); outline: none; } + +/* Filter label */ +.al-filter-label { + font-size: 0.75rem; + font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.05em; + white-space: nowrap; + flex-shrink: 0; +} + +.al-filter-label-sep { margin-left: var(--space-sm); } + +/* Category / severity chips */ +.al-chip-group { + display: flex; + gap: 4px; + flex-wrap: wrap; +} + +.al-chip { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 3px 9px; + font-size: 0.75rem; + font-weight: 500; + border: var(--lux-hairline) solid var(--border-color); + border-radius: var(--radius-pill); + background: var(--card-bg); + color: var(--text-secondary); + cursor: pointer; + transition: background var(--duration-fast), color var(--duration-fast), border-color var(--duration-fast); + white-space: nowrap; +} + +.al-chip .icon { width: 12px; height: 12px; } + +.al-chip:hover { + background: var(--bg-secondary); + border-color: var(--text-secondary); + color: var(--text-color); +} + +.al-chip.active { + background: var(--primary-color); + border-color: var(--primary-color); + color: var(--primary-contrast); +} + +/* Severity chip colors when active */ +.al-sev-chip-error.active { background: var(--danger-color); border-color: var(--danger-color); } +.al-sev-chip-warning.active { background: var(--warning-color); border-color: var(--warning-color); } +.al-sev-chip-info.active { background: var(--info-color); border-color: var(--info-color); } + +/* Advanced field row */ +.al-toolbar-advanced { + gap: var(--space-sm); + padding-top: var(--space-xs); + border-top: var(--lux-hairline) solid var(--border-color); +} + +.al-field-group { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 160px; + flex: 1; +} + +.al-field-label { + font-size: 0.6875rem; + font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.al-field-input { + padding: 4px 8px; + background: var(--card-bg); + border: var(--lux-hairline) solid var(--border-color); + border-radius: var(--radius-sm); + color: var(--text-color); + font-size: 0.8125rem; + font-family: var(--font-body); + outline: none; + transition: border-color var(--duration-fast); +} + +.al-field-input:focus { + border-color: var(--primary-color); + box-shadow: 0 0 0 2px rgba(76, 175, 80, 0.12); +} + +/* ── List header (count + live dot) ─────────────────────────────────────── */ + +.al-list-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--space-xs) 0; + margin-bottom: var(--space-xs); +} + +.al-count { + font-size: 0.8125rem; + color: var(--text-muted); + font-variant-numeric: tabular-nums; +} + +.al-live-indicator { + display: flex; + align-items: center; + gap: 5px; + font-size: 0.75rem; + font-weight: 500; + color: var(--primary-text-color); + letter-spacing: 0.02em; +} + +.al-live-dot { + width: 7px; + height: 7px; + background: var(--primary-color); + border-radius: 50%; + animation: al-pulse 2s infinite; +} + +@keyframes al-pulse { + 0%, 100% { opacity: 1; transform: scale(1); } + 50% { opacity: 0.55; transform: scale(0.85); } +} + +/* ── Entry rows ─────────────────────────────────────────────────────────── */ + +.al-list { + display: flex; + flex-direction: column; + gap: 1px; +} + +.al-entry { + /* Same elevated surface + hairline as entity cards (see .al-toolbar). */ + background: var(--lux-bg-1, var(--card-bg)); + border: var(--lux-hairline) solid var(--lux-line, var(--border-color)); + border-radius: var(--radius-sm); + overflow: hidden; + transition: border-color var(--duration-fast); +} + +.al-entry:hover { border-color: var(--text-muted); } + +/* New-entry flash — settles on the card surface (animation-fill-mode: forwards + holds the 100% frame, so it must match the .al-entry background exactly). */ +@keyframes al-new-flash { + 0% { background: color-mix(in srgb, var(--primary-color) 18%, var(--lux-bg-1, var(--card-bg))); } + 100% { background: var(--lux-bg-1, var(--card-bg)); } +} + +.al-entry-new.al-entry-appear { animation: al-new-flash 1.8s var(--ease-out) forwards; } + +.al-entry-row { + display: grid; + /* icon | time | badge | actor | message | entity | chevron + badge is fixed so all category names (AUTH…CAPTURE) occupy identical + width; actor is capped so long usernames don't push the message over; + message takes all remaining space. */ + grid-template-columns: 24px 80px 78px minmax(0, 110px) 1fr auto 20px; + align-items: center; + gap: var(--space-sm); + padding: var(--space-xs) var(--space-md); + cursor: pointer; + min-height: 36px; + outline: none; +} + +.al-entry-row:focus-visible { box-shadow: inset 0 0 0 2px var(--primary-color); } + +/* Severity rail icon */ +.al-sev { display: flex; align-items: center; justify-content: center; } +.al-sev .icon { width: 14px; height: 14px; } +.al-sev-info .icon { color: var(--info-color); } +.al-sev-warning .icon { color: var(--warning-color); } +.al-sev-error .icon { color: var(--danger-color); } + +/* Time */ +.al-time { + font-size: 0.75rem; + font-variant-numeric: tabular-nums; + font-family: var(--font-mono); + color: var(--text-muted); + white-space: nowrap; +} + +/* Category badge */ +.al-cat-badge { + display: inline-block; + padding: 1px 7px; + font-size: 0.6875rem; + font-weight: 600; + letter-spacing: 0.03em; + text-transform: uppercase; + border-radius: var(--radius-pill); + white-space: nowrap; + border: var(--lux-hairline) solid transparent; +} + +/* Per-category colors — subtle tinted backgrounds */ +.al-cat-auth { background: rgba(33, 150, 243, 0.12); color: var(--info-color); border-color: rgba(33, 150, 243, 0.25); } +.al-cat-device { background: rgba(156, 39, 176, 0.10); color: #ab47bc; border-color: rgba(156, 39, 176, 0.22); } +.al-cat-entity { background: rgba(76, 175, 80, 0.12); color: var(--primary-text-color); border-color: rgba(76, 175, 80, 0.25); } +.al-cat-capture { background: rgba(255, 152, 0, 0.12); color: var(--warning-color); border-color: rgba(255, 152, 0, 0.25); } +.al-cat-system { background: rgba(120, 120, 120, 0.12); color: var(--text-secondary); border-color: rgba(120, 120, 120, 0.25); } + +[data-theme="light"] .al-cat-auth { background: rgba(33, 150, 243, 0.08); } +/* Darker purple text in light theme — the dark-theme #ab47bc fails AA contrast + on the pale tinted background at this small badge size. */ +[data-theme="light"] .al-cat-device { background: rgba(156, 39, 176, 0.08); color: #8e24aa; } +[data-theme="light"] .al-cat-entity { background: rgba(76, 175, 80, 0.08); } +[data-theme="light"] .al-cat-capture { background: rgba(255, 152, 0, 0.08); } +[data-theme="light"] .al-cat-system { background: rgba(120, 120, 120, 0.08); } + +/* Actor — constrained by its grid column (minmax(0, 110px)) */ +.al-actor { + font-size: 0.8125rem; + font-family: var(--font-mono); + color: var(--text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; +} + +/* Message — min-width:0 lets the 1fr column actually truncate */ +.al-msg { + font-size: 0.8125rem; + color: var(--text-color); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; + text-align: left; +} + +/* Entity crosslink */ +.al-entity { display: flex; align-items: center; } + +.al-entity-link { + font-size: 0.75rem; + color: var(--primary-text-color); + background: none; + border: none; + padding: 0; + cursor: pointer; + text-decoration: underline dotted; + text-underline-offset: 2px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 120px; + display: block; +} + +.al-entity-link:hover { color: var(--primary-hover); text-decoration-style: solid; } + +.al-entity-name { + font-size: 0.75rem; + color: var(--text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Expand chevron */ +.al-expand-chevron { + font-size: 0.6875rem; + color: var(--text-muted); + justify-self: end; + user-select: none; +} + +/* ── Entry detail drawer ─────────────────────────────────────────────────── */ + +.al-detail { + padding: var(--space-sm) var(--space-md) var(--space-md); + border-top: var(--lux-hairline) solid var(--border-color); + background: var(--bg-secondary); + animation: al-detail-open var(--duration-fast) var(--ease-out); +} + +@keyframes al-detail-open { + from { opacity: 0; transform: translateY(-4px); } + to { opacity: 1; transform: translateY(0); } +} + +.al-detail-grid { + display: grid; + grid-template-columns: max-content 1fr; + gap: 4px var(--space-md); + font-size: 0.8125rem; +} + +.al-detail-grid dt { + color: var(--text-muted); + font-weight: 600; + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.04em; + align-self: start; + padding-top: 2px; + white-space: nowrap; +} + +.al-detail-grid dd { + color: var(--text-color); + word-break: break-all; +} + +.al-detail-grid code { + font-family: var(--font-mono); + font-size: 0.8125rem; + background: var(--bg-color); + padding: 1px 5px; + border-radius: var(--radius-sm); + border: var(--lux-hairline) solid var(--border-color); +} + +.al-meta-pre { + font-family: var(--font-mono); + font-size: 0.75rem; + background: var(--bg-color); + border: var(--lux-hairline) solid var(--border-color); + border-radius: var(--radius-sm); + padding: var(--space-sm); + overflow-x: auto; + white-space: pre; + color: var(--text-secondary); + max-height: 220px; + overflow-y: auto; + margin: 0; +} + +/* ── Load More ───────────────────────────────────────────────────────────── */ + +.al-load-more { + display: block; + width: 100%; + margin-top: var(--space-md); + text-align: center; + font-size: 0.875rem; +} + +/* ── Empty / loading / error states ─────────────────────────────────────── */ + +.al-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--space-sm); + padding: var(--space-xl); + color: var(--text-muted); + font-size: 0.875rem; + text-align: center; +} + +.al-state-icon .icon { + width: 36px; + height: 36px; + opacity: 0.35; +} + +.al-loading { flex-direction: row; padding: var(--space-lg); } + +.al-spinner { + width: 20px; + height: 20px; + border: 2px solid var(--border-color); + border-top-color: var(--primary-color); + border-radius: 50%; + animation: al-spin 0.6s linear infinite; + flex-shrink: 0; +} + +@keyframes al-spin { to { transform: rotate(360deg); } } + +.al-error .al-state-icon .icon { color: var(--danger-color); opacity: 0.6; } + +/* ── List container ──────────────────────────────────────────────────────── */ + +.al-list-container { + flex: 1; + min-height: 0; +} + +/* Subtle busy state while a slow re-query is in flight: the current rows stay + visible (no spinner flash) but dim slightly and stop accepting clicks until + the fresh results swap in. Only applied after a short delay, so instant + filtering shows nothing. */ +.al-list-container.al-busy { + opacity: 0.55; + pointer-events: none; + transition: opacity var(--duration-fast) var(--ease-out); +} + +/* ── Tabular-nums utility ────────────────────────────────────────────────── */ + +.tabular-nums { font-variant-numeric: tabular-nums; } + +/* ── Responsive ──────────────────────────────────────────────────────────── */ + +@media (max-width: 900px) { + .al-entry-row { + /* icon | time | badge (fixed) | message | chevron — actor+entity hidden */ + grid-template-columns: 20px 70px 78px 1fr 18px; + } + /* Hide actor and entity link at small widths */ + .al-actor, + .al-entity { display: none; } +} + +@media (max-width: 600px) { + .al-panel { padding: var(--space-sm); } + + .al-entry-row { + grid-template-columns: 20px 1fr auto 18px; + grid-template-rows: auto auto; + gap: 4px var(--space-xs); + } + + /* Row 1: [sev] [message] [badge] [chevron]; Row 2: [time] under the message. + message stays in its own 1fr column so it never overlaps the badge. */ + .al-time { grid-column: 2; grid-row: 2; font-size: 0.6875rem; } + .al-cat-badge{ grid-column: 3; grid-row: 1; } + .al-msg { grid-column: 2; grid-row: 1; } + + .al-toolbar-advanced .al-field-group { min-width: 100%; } +} + +/* ───────────────────────────────────────────────────────────────────────── + Dashboard "Recent Activity" widget (.dal-*) + Compact, consistent with the precision-instrument language of the full tab. + Rows are tighter than the full viewer — just sev icon + relative time + msg. + ───────────────────────────────────────────────────────────────────────── */ + +/* List container */ +.dal-list { + display: flex; + flex-direction: column; + gap: 0; +} + +/* Compact entry row */ +.al-compact-row { + display: grid; + grid-template-columns: 18px 52px 1fr; + align-items: center; + gap: 0 8px; + padding: 5px 4px; + border-bottom: 1px solid var(--border-color); + min-height: 28px; + transition: background 0.12s; +} + +.al-compact-row:last-child { + border-bottom: none; +} + +.al-compact-row:hover { + background: var(--bg-secondary); +} + +.al-compact-icon .icon { + width: 14px; + height: 14px; + display: block; + flex-shrink: 0; +} + +.al-compact-time { + font-family: var(--font-mono, monospace); + font-size: 0.6875rem; + color: var(--text-muted); + white-space: nowrap; + font-variant-numeric: tabular-nums; +} + +.al-compact-msg { + font-size: 0.8125rem; + color: var(--text-color); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Severity color on the row itself (row inherits .al-sev-* from renderCompactEntry) */ +.al-compact-row.al-sev-error .al-compact-icon .icon { color: var(--danger-color); } +.al-compact-row.al-sev-warning .al-compact-icon .icon { color: var(--warning-color); } +.al-compact-row.al-sev-info .al-compact-icon .icon { color: var(--info-color); } + +/* Empty state inside widget */ +.dal-empty { + padding: 16px 8px; +} +.dal-empty p { + font-size: 0.8125rem; + color: var(--text-muted); +} + +/* Loading state placeholder */ +.dal-loading { + padding: 16px 8px; + display: flex; + align-items: center; + justify-content: center; +} + +/* Footer — "View all →" link */ +.dal-footer { + padding: 6px 4px 2px; + display: flex; + justify-content: flex-end; +} + +.dal-view-all { + font-size: 0.8125rem; + color: var(--primary-text-color); + text-decoration: none; + border: none; + background: none; + cursor: pointer; + padding: 2px 4px; + transition: opacity 0.15s; +} + +.dal-view-all:hover { + opacity: 0.75; + text-decoration: underline; +} + +/* ───────────────────────────────────────────────────────────────────────── + Settings panel helpers (ds-info-note, ds-inline-link) + These are general enough to live here but scoped tightly enough to not + bleed into the rest of the settings layout. + ───────────────────────────────────────────────────────────────────────── */ + +.ds-info-note { + display: flex; + align-items: flex-start; + gap: 8px; + padding: 10px 14px; + margin-bottom: 16px; + background: color-mix(in srgb, var(--info-color) 8%, var(--bg-secondary)); + border: 1px solid color-mix(in srgb, var(--info-color) 25%, var(--border-color)); + border-radius: var(--radius-sm, 4px); + font-size: 0.8125rem; + color: var(--text-color); + line-height: 1.5; +} + +.ds-info-note .icon { + width: 15px; + height: 15px; + flex-shrink: 0; + margin-top: 1px; + color: var(--info-color); +} + +/* Inline text button that looks like a link (used in ds-info-note, hints) */ +.ds-inline-link { + background: none; + border: none; + padding: 0; + margin: 0; + cursor: pointer; + color: var(--primary-text-color); + font-size: inherit; + font-family: inherit; + text-decoration: underline; + text-underline-offset: 2px; + display: inline; +} + +.ds-inline-link:hover { + opacity: 0.8; +} diff --git a/server/src/ledgrab/static/css/all.css b/server/src/ledgrab/static/css/all.css index 0e01d61..43f828e 100644 --- a/server/src/ledgrab/static/css/all.css +++ b/server/src/ledgrab/static/css/all.css @@ -19,5 +19,6 @@ @import './graph-editor.css'; @import './appearance.css'; @import './game-integration.css'; +@import './activity-log.css'; @import './mobile.css'; @import './tv.css'; diff --git a/server/src/ledgrab/static/css/calibration.css b/server/src/ledgrab/static/css/calibration.css index b4c5b3c..6cf8f99 100644 --- a/server/src/ledgrab/static/css/calibration.css +++ b/server/src/ledgrab/static/css/calibration.css @@ -79,6 +79,12 @@ opacity: 1; } +.preview-screen-total:focus-visible { + opacity: 1; + outline: 2px solid var(--primary-color); + outline-offset: 2px; +} + .preview-screen-total.mismatch { color: #FFC107; } @@ -123,6 +129,12 @@ background: rgba(128, 128, 128, 0.25); } +.edge-toggle:focus-visible { + background: rgba(128, 128, 128, 0.25); + outline: 2px solid var(--primary-color); + outline-offset: 2px; +} + .preview-edge.edge-disabled { opacity: 0.25; pointer-events: none; @@ -374,6 +386,13 @@ color: rgba(76, 175, 80, 0.6); } +.preview-corner:focus-visible { + color: rgba(76, 175, 80, 0.6); + outline: 2px solid var(--primary-color); + outline-offset: 2px; + border-radius: 4px; +} + .preview-corner.active:hover { transform: none; } @@ -412,6 +431,12 @@ background: rgba(255, 255, 255, 0.25); } +.direction-toggle:focus-visible { + background: rgba(255, 255, 255, 0.25); + outline: 2px solid var(--primary-color); + outline-offset: 2px; +} + .direction-toggle #direction-icon { font-size: 14px; } diff --git a/server/src/ledgrab/static/css/components.css b/server/src/ledgrab/static/css/components.css index 2f390dc..d1f8bb5 100644 --- a/server/src/ledgrab/static/css/components.css +++ b/server/src/ledgrab/static/css/components.css @@ -328,7 +328,7 @@ select.field-invalid { display: block; border: 1px solid var(--border-color); border-radius: var(--radius-sm); - background: var(--surface-2, color-mix(in srgb, var(--text-color) 6%, var(--bg-color))); + background: var(--lux-bg-2); overflow: hidden; transition: border-color 0.15s ease, box-shadow 0.15s ease; } @@ -396,10 +396,10 @@ select.field-invalid { /* Token palette — restrained, three accents plus muted operators. */ .jinja-hl .tok-str { color: var(--success-color); } -.jinja-hl .tok-num { color: #d19a66; } +.jinja-hl .tok-num { color: var(--ch-amber); } .jinja-hl .tok-fn { color: var(--primary-color); font-weight: 600; } -.jinja-hl .tok-raw { color: #c678dd; font-style: italic; } -.jinja-hl .tok-var { color: #61afef; } +.jinja-hl .tok-raw { color: var(--ch-violet); font-style: italic; } +.jinja-hl .tok-var { color: var(--ch-cyan); } .jinja-hl .tok-op { color: var(--text-muted); } /* ── Template-input rows ─────────────────────────────────────── */ @@ -496,8 +496,8 @@ select.field-invalid { font-size: 0.76rem; padding: 1px 6px; border-radius: var(--radius-pill); - background: color-mix(in srgb, #61afef 16%, transparent); - color: #61afef; + background: color-mix(in srgb, var(--ch-cyan) 16%, transparent); + color: var(--ch-cyan); } .jinja-hints-examples { margin: 4px 0 0; padding-left: 0; list-style: none; } @@ -918,7 +918,12 @@ input:-webkit-autofill:focus { .discovery-item:focus-visible, .tag-chip-remove:focus-visible, .cs-filter-reset:focus-visible, -.graph-filter-clear:focus-visible { +.graph-filter-clear:focus-visible, +.wizard-discovery-item:focus-visible, +.wizard-display-item:focus-visible, +.autocal-corner-btn:focus-visible, +.autocal-direction-btn:focus-visible, +.scene-target-add-slot:focus-visible { outline: 2px solid var(--primary-color); outline-offset: 2px; } @@ -1118,7 +1123,8 @@ textarea:focus-visible { font-weight: 700; line-height: 1; } -.scene-target-add-slot:hover:not(:disabled) { +.scene-target-add-slot:hover:not(:disabled), +.scene-target-add-slot:focus-visible:not(:disabled) { background: repeating-linear-gradient(135deg, color-mix(in srgb, var(--st-ch) 12%, transparent) 0 6px, @@ -1625,14 +1631,14 @@ textarea:focus-visible { padding: 6px 12px; border: 1px solid var(--border-color, #333); border-radius: 6px; - background: var(--surface-2, #1e1e2e); + background: var(--lux-bg-2); color: var(--text-secondary, #999); font-size: 0.8rem; cursor: pointer; transition: background 0.15s, color 0.15s, border-color 0.15s; } .type-picker-tab:hover { - background: var(--surface-3, #2a2a3e); + background: var(--lux-bg-3); color: var(--text-primary, #e0e0e0); } .type-picker-tab.active { @@ -2326,7 +2332,7 @@ textarea:focus-visible { .autocal-step-desc { font-size: 0.85rem; - color: var(--text-muted, var(--secondary-text-color)); + color: var(--text-muted); line-height: 1.5; margin: 0; } @@ -2355,7 +2361,8 @@ textarea:focus-visible { font-weight: 500; text-align: center; } -.autocal-corner-btn:hover { +.autocal-corner-btn:hover, +.autocal-corner-btn:focus-visible { border-color: var(--primary-color); background: color-mix(in srgb, var(--primary-color) 8%, var(--card-bg)); color: var(--primary-color); @@ -2424,7 +2431,8 @@ textarea:focus-visible { font-weight: 500; text-align: center; } -.autocal-direction-btn:hover { +.autocal-direction-btn:hover, +.autocal-direction-btn:focus-visible { border-color: var(--primary-color); background: color-mix(in srgb, var(--primary-color) 8%, var(--card-bg)); color: var(--primary-color); @@ -2442,7 +2450,7 @@ textarea:focus-visible { border: 1px solid color-mix(in srgb, var(--primary-color) 20%, var(--border-color)); border-radius: var(--radius-sm, 6px); font-size: 0.8rem; - color: var(--text-muted, var(--secondary-text-color)); + color: var(--text-muted); } .autocal-led-dot { @@ -2489,7 +2497,7 @@ textarea:focus-visible { justify-content: center; font-size: 0.7rem; font-weight: 700; - color: var(--text-muted, var(--secondary-text-color)); + color: var(--text-muted); transition: border-color 0.2s, background 0.2s, color 0.2s; flex-shrink: 0; } @@ -2618,7 +2626,7 @@ textarea:focus-visible { } .autocal-solved-key { - color: var(--text-muted, var(--secondary-text-color)); + color: var(--text-muted); flex-shrink: 0; min-width: 68px; } @@ -2720,7 +2728,7 @@ textarea:focus-visible { font-size: 0.7rem; font-weight: 700; background: var(--bg-secondary, var(--bg-2, #2a2a2a)); - color: var(--text-muted, var(--secondary-text-color)); + color: var(--text-muted); border: 1.5px solid var(--border-color); transition: background 0.2s, color 0.2s, border-color 0.2s; } @@ -2775,7 +2783,7 @@ textarea:focus-visible { } .wizard-step-desc { font-size: 0.85rem; - color: var(--text-muted, var(--secondary-text-color)); + color: var(--text-muted); line-height: 1.5; margin: 0; } @@ -2829,7 +2837,7 @@ textarea:focus-visible { font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase; - color: var(--text-muted, var(--secondary-text-color)); + color: var(--text-muted); padding-bottom: 4px; } .wizard-section-label--scan { @@ -2860,7 +2868,7 @@ textarea:focus-visible { gap: 10px; padding: 14px 12px; font-size: 0.85rem; - color: var(--text-muted, var(--secondary-text-color)); + color: var(--text-muted); background: var(--card-bg); border: 1px solid var(--border-color); border-radius: var(--radius-md, 8px); @@ -2868,7 +2876,7 @@ textarea:focus-visible { .wizard-discovery-empty { padding: 14px 12px; font-size: 0.85rem; - color: var(--text-muted, var(--secondary-text-color)); + color: var(--text-muted); background: var(--card-bg); border: 1px solid var(--border-color); border-radius: var(--radius-md, 8px); @@ -2891,7 +2899,8 @@ textarea:focus-visible { width: 100%; transition: border-color 0.15s, background 0.15s; } -.wizard-discovery-item:hover { +.wizard-discovery-item:hover, +.wizard-discovery-item:focus-visible { border-color: var(--primary-color); background: color-mix(in srgb, var(--primary-color) 5%, var(--card-bg)); } @@ -2899,7 +2908,7 @@ textarea:focus-visible { .wizard-discovery-icon .icon { width: 20px; height: 20px; } .wizard-discovery-details { display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 0; } .wizard-discovery-name { font-size: 0.88rem; font-weight: 600; color: var(--text-color); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } -.wizard-discovery-url { font-size: 0.78rem; color: var(--text-muted, var(--secondary-text-color)); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.wizard-discovery-url { font-size: 0.78rem; color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .wizard-discovery-badge { font-size: 0.68rem; font-weight: 700; @@ -2926,7 +2935,8 @@ textarea:focus-visible { width: 100%; transition: border-color 0.15s, background 0.15s; } -.wizard-display-item:hover { +.wizard-display-item:hover, +.wizard-display-item:focus-visible { border-color: var(--primary-color); background: color-mix(in srgb, var(--primary-color) 5%, var(--card-bg)); } @@ -2938,7 +2948,7 @@ textarea:focus-visible { .wizard-display-icon .icon { width: 20px; height: 20px; } .wizard-display-details { display: flex; flex-direction: column; gap: 2px; flex: 1; } .wizard-display-name { font-size: 0.88rem; font-weight: 600; color: var(--text-color); } -.wizard-display-dims { font-size: 0.78rem; color: var(--text-muted, var(--secondary-text-color)); } +.wizard-display-dims { font-size: 0.78rem; color: var(--text-muted); } .wizard-display-check { color: var(--primary-color); } .wizard-display-check .icon { width: 16px; height: 16px; } .wizard-display-fallback { display: flex; flex-direction: column; gap: 12px; } @@ -2953,7 +2963,7 @@ textarea:focus-visible { border: 1px solid var(--border-color); border-radius: var(--radius-md, 8px); } -.wizard-scaffold-label { font-size: 0.88rem; color: var(--text-muted, var(--secondary-text-color)); } +.wizard-scaffold-label { font-size: 0.88rem; color: var(--text-muted); } /* Calibrate container */ .wizard-calibrate-container { @@ -2990,7 +3000,7 @@ textarea:focus-visible { padding: 12px 16px; } .wizard-done-item { display: flex; justify-content: space-between; align-items: center; font-size: 0.85rem; gap: 12px; } -.wizard-done-label { color: var(--text-muted, var(--secondary-text-color)); } +.wizard-done-label { color: var(--text-muted); } .wizard-done-value { font-weight: 600; color: var(--text-color); text-align: right; } /* Wizard form rows */ diff --git a/server/src/ledgrab/static/css/modal.css b/server/src/ledgrab/static/css/modal.css index 8ca6cd2..718b500 100644 --- a/server/src/ledgrab/static/css/modal.css +++ b/server/src/ledgrab/static/css/modal.css @@ -2512,7 +2512,11 @@ padding-top: 8px; } -.error-message { +/* `.modal-error` is the convention recommended in contexts/frontend.md and is + used by several modals; it aliases `.error-message` so both render the same + inline error banner. */ +.error-message, +.modal-error { background: color-mix(in srgb, var(--danger-color) 10%, transparent); /* --danger-color tint */ border: 1px solid var(--danger-color); color: var(--danger-color); diff --git a/server/src/ledgrab/static/js/app.ts b/server/src/ledgrab/static/js/app.ts index 9c018d5..735deb0 100644 --- a/server/src/ledgrab/static/js/app.ts +++ b/server/src/ledgrab/static/js/app.ts @@ -228,6 +228,24 @@ import { mountAutoCalibration, unmountAutoCalibration, } from './features/auto-calibration.ts'; +// Layer 5: activity log +import { + loadActivityLog, + activityLogToggleDetail, + activityLogToggleCat, + activityLogToggleSev, + activityLogOnSearch, + activityLogOnActor, + activityLogOnEntityType, + activityLogOnSince, + activityLogOnUntil, + activityLogClearFilters, + activityLogPreset, + activityLogLoadMore, + activityLogExport, + activityLogNavigateToEntity, +} from './features/activity-log.ts'; + // Layer 5.5: graph editor import { loadGraphEditor, @@ -257,6 +275,7 @@ import { loadDaylightTimezone, saveDaylightTimezone, requestNotifPermissionFromSettings, testNotifFromSettings, saveExternalUrl, revertExternalUrl, getBaseOrigin, loadExternalUrl, + loadActivityLogSettings, saveActivityLogSettings, activityLogSettingsExport, clearActivityLog, } from './features/settings.ts'; import { loadUpdateStatus, initUpdateListener, checkForUpdates, @@ -741,6 +760,10 @@ Object.assign(window, { saveExternalUrl, revertExternalUrl, getBaseOrigin, + loadActivityLogSettings, + saveActivityLogSettings, + activityLogSettingsExport, + clearActivityLog, // update checkForUpdates, @@ -762,6 +785,22 @@ Object.assign(window, { applyStylePreset, applyBgEffect, renderAppearanceTab, + + // activity log + loadActivityLog, + activityLogToggleDetail, + activityLogToggleCat, + activityLogToggleSev, + activityLogOnSearch, + activityLogOnActor, + activityLogOnEntityType, + activityLogOnSince, + activityLogOnUntil, + activityLogClearFilters, + activityLogPreset, + activityLogLoadMore, + activityLogExport, + activityLogNavigateToEntity, }); // ─── Global keyboard shortcuts ─── @@ -779,7 +818,7 @@ document.addEventListener('keydown', (e) => { // Tab shortcuts: Ctrl+1..4 (skip when typing in inputs) if (!inInput && e.ctrlKey && !e.altKey && !e.shiftKey && !e.metaKey) { - const tabMap = { '1': 'dashboard', '2': 'automations', '3': 'targets', '4': 'streams', '5': 'integrations', '6': 'graph' }; + const tabMap = { '1': 'dashboard', '2': 'automations', '3': 'targets', '4': 'streams', '5': 'integrations', '6': 'graph', '7': 'activity_log' }; const tab = tabMap[e.key]; if (tab) { e.preventDefault(); diff --git a/server/src/ledgrab/static/js/core/events-ws.ts b/server/src/ledgrab/static/js/core/events-ws.ts index 507b8f3..f46f9b4 100644 --- a/server/src/ledgrab/static/js/core/events-ws.ts +++ b/server/src/ledgrab/static/js/core/events-ws.ts @@ -32,6 +32,7 @@ import { openAuthedWs } from './ws-auth.ts'; * update_download_progress — update_service.py (consumed by features/update.ts) * device_discovered — discovery_watcher.py (consumed by features/notifications-watcher.ts) * device_lost — discovery_watcher.py (consumed by features/notifications-watcher.ts) + * activity_logged — core/activity_log/recorder.py (consumed by features/activity-log.ts) * * Missing any of these silently breaks the corresponding UI flow — keep * this list in sync when adding new event types on the server side. @@ -47,6 +48,7 @@ const _ALLOWED_SERVER_EVENT_TYPES: ReadonlySet = new Set([ 'update_download_progress', 'device_discovered', 'device_lost', + 'activity_logged', // source: core/activity_log/recorder.py ]); interface ServerEventEnvelope { diff --git a/server/src/ledgrab/static/js/core/icon-paths.ts b/server/src/ledgrab/static/js/core/icon-paths.ts index 9a196a4..15f5871 100644 --- a/server/src/ledgrab/static/js/core/icon-paths.ts +++ b/server/src/ledgrab/static/js/core/icon-paths.ts @@ -135,6 +135,17 @@ export const armchair = ' // Lucide: leaf export const leaf = ''; +// Lucide: scroll-text (audit / activity log) +export const scrollText = ''; +// Lucide: circle-alert (error severity) +export const circleAlert = ''; +// Lucide: info (info severity) +export const info = ''; +// Lucide: filter (filter toolbar) +export const filter = ''; +// Lucide: x-circle (clear/reset) +export const xCircle = ''; + // Easing curve glyphs — custom mini-charts that draw the actual curve. // Curve travels from (4, 20) to (20, 4); each path renders the easing // function directly so the picker shows the shape, not a metaphor. diff --git a/server/src/ledgrab/static/js/core/icons.ts b/server/src/ledgrab/static/js/core/icons.ts index f8aed9e..2744c3b 100644 --- a/server/src/ledgrab/static/js/core/icons.ts +++ b/server/src/ledgrab/static/js/core/icons.ts @@ -354,6 +354,15 @@ export const ICON_CIRCLE = _svg(P.circle); export const ICON_GIT_MERGE = _svg(P.gitMerge); export const ICON_COPY = _svg(P.copy); +// ── Activity log icons ───────────────────────────────────── + +export const ICON_ACTIVITY_LOG = _svg(P.scrollText); +export const ICON_SEVERITY_INFO = _svg(P.info); +export const ICON_SEVERITY_WARN = _svg(P.triangleAlert); +export const ICON_SEVERITY_ERR = _svg(P.circleAlert); +export const ICON_FILTER = _svg(P.filter); +export const ICON_X_CIRCLE = _svg(P.xCircle); + // ── Game integration icons ───────────────────────────────── export const ICON_GAMEPAD = _svg(P.gamepad2); diff --git a/server/src/ledgrab/static/js/core/tab-registry.ts b/server/src/ledgrab/static/js/core/tab-registry.ts index c63dd74..b13ea16 100644 --- a/server/src/ledgrab/static/js/core/tab-registry.ts +++ b/server/src/ledgrab/static/js/core/tab-registry.ts @@ -28,6 +28,7 @@ const TAB_REGISTRY: Readonly> = { automations: { loadFnName: 'loadAutomations', subTab: { storageKey: 'activeAutomationTab', defaultSubTab: 'automations', switchFnName: 'switchAutomationTab' } }, graph: { loadFnName: 'loadGraphEditor' }, + activity_log: { loadFnName: 'loadActivityLog', autoRefresh: false }, }; /** Get the full config for a tab, or undefined if not registered. */ diff --git a/server/src/ledgrab/static/js/core/ui.ts b/server/src/ledgrab/static/js/core/ui.ts index 39b7366..77fed90 100644 --- a/server/src/ledgrab/static/js/core/ui.ts +++ b/server/src/ledgrab/static/js/core/ui.ts @@ -555,6 +555,90 @@ export function formatCompact(n: number | null | undefined) { return (v < 10 ? v.toFixed(1) : Math.round(v)) + 'B'; } +/** + * Format an ISO-8601 timestamp (or ms epoch) into a locale-aware string. + * Returns "Today · HH:MM", "Yesterday · HH:MM", or "DD MMM · HH:MM". + * Use `font-variant-numeric: tabular-nums` on the element for stable layout. + */ +export function formatTimestamp(isoOrMs: string | number): string { + const d = typeof isoOrMs === 'number' ? new Date(isoOrMs) : new Date(isoOrMs); + if (isNaN(d.getTime())) return String(isoOrMs); + + const now = new Date(); + const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const yestStart = new Date(todayStart.getTime() - 86400000); + + const hhmm = d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false }); + + if (d >= todayStart) { + return `${t('time.today')} · ${hhmm}`; + } else if (d >= yestStart) { + return `${t('time.yesterday')} · ${hhmm}`; + } else { + const dateStr = d.toLocaleDateString([], { day: 'numeric', month: 'short' }); + return `${dateStr} · ${hhmm}`; + } +} + +/** + * Format an ISO-8601 timestamp (or ms epoch) as a compact relative string. + * Examples: "just now", "2m ago", "3h ago", "5d ago". + * Use font-variant-numeric: tabular-nums on elements that update frequently. + */ +export function formatRelativeTime(isoOrMs: string | number): string { + const d = typeof isoOrMs === 'number' ? new Date(isoOrMs) : new Date(isoOrMs); + if (isNaN(d.getTime())) return String(isoOrMs); + + const diffSec = Math.floor((Date.now() - d.getTime()) / 1000); + if (diffSec < 10) return t('time.just_now'); + if (diffSec < 60) return t('time.seconds_ago', { n: diffSec }); + const diffMin = Math.floor(diffSec / 60); + if (diffMin < 60) return t('time.minutes_ago', { n: diffMin }); + const diffHr = Math.floor(diffMin / 60); + if (diffHr < 24) return t('time.hours_ago', { n: diffHr }); + const diffDays = Math.floor(diffHr / 24); + return t('time.days_ago', { n: diffDays }); +} + +// ── Shared relative-time ticker ────────────────────────────────────────────── +// A single process-wide interval that keeps every `[data-reltime]` element +// up to date. Call `ensureRelativeTimeTicker()` from any feature that renders +// such elements — repeated calls are idempotent (one interval, ever). + +let _relTimeIntervalId: ReturnType | null = null; +let _relTimeVisibilityBound = false; + +/** Refresh every `[data-reltime]` element's text content to the current + * relative-time label produced by `formatRelativeTime`. */ +function _tickRelativeTimes(): void { + if (document.hidden) return; + document.querySelectorAll('[data-reltime]').forEach(el => { + const iso = el.getAttribute('data-reltime'); + if (iso) el.textContent = formatRelativeTime(iso); + }); +} + +/** + * Start the shared relative-time ticker (idempotent — safe to call many times). + * Ticks every 30 s, skips work when the tab is hidden, and fires one + * immediate refresh when the tab becomes visible again. + * Also fires one immediate refresh on each `languageChanged` event so + * freshly-translated labels appear without waiting for the next tick. + */ +export function ensureRelativeTimeTicker(): void { + // One-time visibility + language listeners + if (!_relTimeVisibilityBound) { + _relTimeVisibilityBound = true; + document.addEventListener('visibilitychange', () => { + if (!document.hidden) _tickRelativeTimes(); + }); + document.addEventListener('languageChanged', () => _tickRelativeTimes()); + } + // Idempotent: only start the interval once + if (_relTimeIntervalId !== null) return; + _relTimeIntervalId = setInterval(_tickRelativeTimes, 30_000); +} + export function formatUptime(seconds: number | null | undefined): string { if (!seconds || seconds <= 0) return '-'; const total = Math.floor(seconds); diff --git a/server/src/ledgrab/static/js/features/activity-log.ts b/server/src/ledgrab/static/js/features/activity-log.ts new file mode 100644 index 0000000..7f0165a --- /dev/null +++ b/server/src/ledgrab/static/js/features/activity-log.ts @@ -0,0 +1,874 @@ +/** + * Activity Log tab — persistent, queryable audit log viewer. + * + * This is a READ-ONLY viewer (no CRUD), differentiated from the debug + * Log Viewer (utils/log_broadcaster.py) which is an ephemeral 500-line tail. + * This tab shows structured, semantic audit entries backed by the SQLite + * activity_log table. + * + * Phase 5: Activity tab + smart filtering + live updates. + */ + +import { fetchWithAuth, escapeHtml } from '../core/api.ts'; +import { t } from '../core/i18n.ts'; +import { showToast, formatRelativeTime, ensureRelativeTimeTicker } from '../core/ui.ts'; +import { navigateToCard } from '../core/navigation.ts'; +import { + ICON_ACTIVITY_LOG, ICON_SEVERITY_INFO, ICON_SEVERITY_WARN, ICON_SEVERITY_ERR, + ICON_X_CIRCLE, ICON_DOWNLOAD, ICON_SEARCH, + ICON_CHEVRON_UP, ICON_CHEVRON_DOWN, +} from '../core/icons.ts'; + +/** + * Escape a string for safe use inside an HTML attribute value (quoted with + * either `"` or `'`). Extends escapeHtml's `<>&` coverage with `"` and `'`. + */ +function _escapeAttr(text: string): string { + if (!text) return ''; + return escapeHtml(text) + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +// ─── Types ─────────────────────────────────────────────────── + +export interface ActivityEntry { + id: string; + ts: string; + category: string; + action: string; + severity: string; + actor: string; + entity_type: string | null; + entity_id: string | null; + entity_name: string | null; + message: string; + metadata: Record; +} + +interface ActivityPage { + entries: ActivityEntry[]; + next_before_seq: number | null; + has_more: boolean; + total: number; +} + +interface ActiveFilters { + categories: string[]; + severities: string[]; + actor: string; + entity_type: string; + since: string; + until: string; + q: string; +} + +// ─── Module state ──────────────────────────────────────────── + +let _initialized = false; +let _loading = false; +let _entries: ActivityEntry[] = []; +let _nextBeforeSeq: number | null = null; +let _hasMore = false; +let _total = 0; +let _expandedIds = new Set(); +let _debounceTimer: ReturnType | null = null; +let _liveEventListener: ((e: Event) => void) | null = null; +// Loading UX: `_showSpinner` gates the full-panel spinner so it only appears +// after a short delay (slow requests), never flashing on instant filtering. +// `_hasLoadedOnce` distinguishes the genuine first load (spinner immediately) +// from re-queries (keep current rows, subtle delayed busy hint). +let _loadingDelayTimer: ReturnType | null = null; +let _showSpinner = false; +let _hasLoadedOnce = false; + +const _filters: ActiveFilters = { + categories: [], + severities: [], + actor: '', + entity_type: '', + since: '', + until: '', + q: '', +}; + +// ─── Category → navigation target map (entity crosslinks) ── + +const _ENTITY_NAV: Record = { + output_target: { tab: 'targets', subTab: 'led-targets', attr: 'data-target-id' }, + device: { tab: 'targets', subTab: 'led-devices', attr: 'data-device-id' }, + picture_source: { tab: 'streams', subTab: 'raw', attr: 'data-stream-id' }, + color_strip_source: { tab: 'streams', subTab: 'color_strip', attr: 'data-css-id' }, + audio_source: { tab: 'streams', subTab: 'audio_capture', attr: 'data-id' }, + automation: { tab: 'automations', subTab: 'automations', attr: 'data-automation-id' }, + scene_preset: { tab: 'automations', subTab: 'scenes', attr: 'data-scene-id' }, + scene_playlist: { tab: 'automations', subTab: 'playlists', attr: 'data-playlist-id' }, +}; + +// ─── Severity icon helper ──────────────────────────────────── + +function _severityIcon(severity: string): string { + if (severity === 'error') return ICON_SEVERITY_ERR; + if (severity === 'warning') return ICON_SEVERITY_WARN; + return ICON_SEVERITY_INFO; +} + +function _severityClass(severity: string): string { + if (severity === 'error') return 'al-sev-error'; + if (severity === 'warning') return 'al-sev-warning'; + return 'al-sev-info'; +} + +// ─── Category label helper ─────────────────────────────────── + +function _categoryLabel(category: string): string { + return t(`activity_log.category.${category}`); +} + +// ─── Localized entity-type label ──────────────────────────── + +function _entityTypeLabel(entityType: string): string { + const key = `activity_log.entity_type.${entityType}`; + const translated = t(key); + // If t() returned the key unchanged there is no translation — humanize it + if (translated === key) { + return entityType.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); + } + return translated; +} + +// ─── Client-side message localization ─────────────────────── +// +// Maps entry.action → an i18n template key and extracts placeholders +// from the structured fields so the displayed description is rendered +// in the user's locale rather than the server-generated English string. +// +// Fallback: if the template key is missing (t() returns the key +// unchanged) we return entry.message (the original server string) so +// the UI always shows something sensible. + +export function localizeMessage(entry: ActivityEntry): string { + const meta = entry.metadata || {}; + + // Build a params bag from structured fields + metadata. + // Keys match the {placeholder} names used in locale templates. + const params: Record = { + name: entry.entity_name ?? '', + actor: entry.actor ?? '', + type: entry.entity_type ? _entityTypeLabel(entry.entity_type) : '', + key: String(meta.setting_key ?? ''), + address: String(meta.address ?? meta.url ?? ''), + reason: String(meta.reason ?? ''), + client: String(meta.client ?? ''), + device_type: String(meta.device_type ?? ''), + filename: String(meta.filename ?? ''), + }; + + // The backend always emits dotted actions (e.g. "entity.created", + // "auth.ws_connected"), so the template key is a direct 1:1 mapping. + const templateKey = `activity_log.msg.${entry.action}`; + + const localized = t(templateKey, params); + // t() returns the key unchanged when there is no matching translation. + if (localized === templateKey) { + return entry.message; + } + return localized; +} + +// ─── Build query string from active filters + cursor ──────── + +function _buildQuery(beforeSeq: number | null = null): string { + const params = new URLSearchParams(); + for (const cat of _filters.categories) params.append('categories', cat); + for (const sev of _filters.severities) params.append('severities', sev); + if (_filters.actor) params.set('actor', _filters.actor); + if (_filters.entity_type) params.set('entity_type', _filters.entity_type); + if (_filters.since) params.set('since', _filters.since); + if (_filters.until) params.set('until', _filters.until); + if (_filters.q) params.set('q', _filters.q); + if (beforeSeq != null) params.set('before_seq', String(beforeSeq)); + params.set('limit', '50'); + const qs = params.toString(); + return qs ? `?${qs}` : ''; +} + +// ─── Entry rendering ───────────────────────────────────────── + +function _renderEntryRow(entry: ActivityEntry, isNew = false): string { + const relTime = formatRelativeTime(entry.ts); + const iso = entry.ts; + const expanded = _expandedIds.has(entry.id); + const sevClass = _severityClass(entry.severity); + const sevIcon = _severityIcon(entry.severity); + + // Entity crosslink — use data-* attributes + delegated listener (no JSON in onclick) + let entityHtml = ''; + if (entry.entity_type && entry.entity_name) { + const nav = _ENTITY_NAV[entry.entity_type]; + if (nav) { + const escapedName = escapeHtml(entry.entity_name); + const attrEntityType = _escapeAttr(entry.entity_type); + const attrEntityId = _escapeAttr(entry.entity_id || ''); + entityHtml = ``; + } else { + entityHtml = `${escapeHtml(entry.entity_name)}`; + } + } + + const detailHtml = expanded ? _renderEntryDetail(entry) : ''; + const attrEntryId = _escapeAttr(entry.id); + + return `
+
+ ${sevIcon} + ${escapeHtml(relTime)} + ${escapeHtml(_categoryLabel(entry.category))} + ${escapeHtml(entry.actor)} + ${escapeHtml(localizeMessage(entry))} + ${entityHtml ? `${entityHtml}` : ''} + +
+ ${detailHtml} +
`; +} + +function _renderEntryDetail(entry: ActivityEntry): string { + const metaJson = JSON.stringify(entry.metadata, null, 2); + const absTime = entry.ts ? new Date(entry.ts).toLocaleString() : ''; + return `
+
+
${escapeHtml(t('activity_log.detail.id'))}
+
${escapeHtml(entry.id)}
+
${escapeHtml(t('activity_log.detail.timestamp'))}
+
${escapeHtml(absTime)}
+
${escapeHtml(t('activity_log.detail.action'))}
+
${escapeHtml(entry.action)}
+
${escapeHtml(t('activity_log.detail.actor'))}
+
${escapeHtml(entry.actor)}
+ ${entry.entity_type ? `
${escapeHtml(t('activity_log.detail.entity'))}
+
${escapeHtml(entry.entity_type)}${entry.entity_id ? ` / ${escapeHtml(entry.entity_id)}` : ''}${entry.entity_name ? ` (${escapeHtml(entry.entity_name)})` : ''}
` : ''} +
${escapeHtml(t('activity_log.detail.metadata'))}
+
${escapeHtml(metaJson)}
+
+
`; +} + +// ─── Filter toolbar rendering ──────────────────────────────── + +const CATEGORIES = ['auth', 'device', 'entity', 'capture', 'system']; +const SEVERITIES = ['info', 'warning', 'error']; + +function _renderFilterToolbar(): string { + const catChips = CATEGORIES.map(cat => { + const active = _filters.categories.includes(cat); + return ``; + }).join(''); + + const sevChips = SEVERITIES.map(sev => { + const active = _filters.severities.includes(sev); + const icon = _severityIcon(sev); + return ``; + }).join(''); + + const presets = [ + { key: 'today', label: t('activity_log.preset.today') }, + { key: 'errors', label: t('activity_log.preset.errors') }, + { key: 'auth', label: t('activity_log.preset.auth') }, + { key: 'devices', label: t('activity_log.preset.devices') }, + ]; + const presetBtns = presets.map(p => + `` + ).join(''); + + const hasFilters = _filters.categories.length || _filters.severities.length || + _filters.actor || _filters.entity_type || _filters.since || _filters.until || _filters.q; + + return ``; +} + +// ─── List and state rendering ──────────────────────────────── + +function _renderList(): string { + if (_showSpinner && _entries.length === 0) { + return `
+
+ ${escapeHtml(t('activity_log.loading'))} +
`; + } + + if (_entries.length === 0) { + const hasFilters = _filters.categories.length || _filters.severities.length || + _filters.actor || _filters.entity_type || _filters.since || _filters.until || _filters.q; + const emptyKey = hasFilters ? 'activity_log.empty' : 'activity_log.empty_no_filters'; + return `
+ +

${escapeHtml(t(emptyKey))}

+
`; + } + + const rows = _entries.map(e => _renderEntryRow(e)).join(''); + const loadMore = _hasMore + ? `` + : ''; + + const countLabel = t('activity_log.n_entries', { n: _total }); + + return `
+ ${escapeHtml(countLabel)} +
+ + ${escapeHtml(t('activity_log.live'))} +
+
+
+ ${rows} +
+ ${loadMore}`; +} + +// ─── Delegated click handler for entry rows and entity links ─ + +let _delegatedClickAttached = false; + +/** Collapse the export dropdown if open (idempotent). */ +function _closeExportMenu(): void { + const wrap = document.getElementById('tab-activity_log') + ?.querySelector('.al-export-wrap.open'); + if (!wrap) return; + wrap.classList.remove('open'); + wrap.querySelector('[data-al-export-toggle]')?.setAttribute('aria-expanded', 'false'); +} + +function _attachDelegatedClicks(): void { + if (_delegatedClickAttached) return; + const panel = document.getElementById('tab-activity_log'); + if (!panel) return; + _delegatedClickAttached = true; + + panel.addEventListener('click', (e: MouseEvent) => { + const target = e.target as HTMLElement; + + // Export menu: toggle button opens/closes the CSV/JSON dropdown. + const exportToggle = target.closest('[data-al-export-toggle]'); + if (exportToggle) { + const wrap = exportToggle.closest('.al-export-wrap'); + const open = wrap?.classList.toggle('open') ?? false; + exportToggle.setAttribute('aria-expanded', open ? 'true' : 'false'); + return; + } + // Export menu: a menu item's inline onclick triggers the download (it + // runs first, on the deeper element) — we just collapse the menu after. + if (target.closest('.al-export-menu')) { + _closeExportMenu(); + return; + } + // Any other click in the panel dismisses an open export menu, then + // continues to row / entity handling below. + _closeExportMenu(); + + // Entity navigation: click on data-entity-type button + const entityBtn = target.closest('button.al-entity-link[data-entity-type]'); + if (entityBtn) { + e.stopPropagation(); + const entityType = entityBtn.dataset.entityType ?? ''; + const entityId = entityBtn.dataset.entityId ?? ''; + activityLogNavigateToEntity(entityType, entityId); + return; + } + + // Entry row toggle: click on al-entry-row with data-toggle-id + const row = target.closest('.al-entry-row[data-toggle-id]'); + if (row) { + const entryId = row.dataset.toggleId ?? ''; + if (entryId) activityLogToggleDetail(entryId); + return; + } + }); + + panel.addEventListener('keydown', (e: KeyboardEvent) => { + // Escape closes the export menu and restores focus to its trigger. + if (e.key === 'Escape') { + const toggle = panel.querySelector('.al-export-wrap.open [data-al-export-toggle]'); + if (toggle) { + _closeExportMenu(); + toggle.focus(); + } + return; + } + if (e.key !== 'Enter' && e.key !== ' ') return; + const row = (e.target as HTMLElement).closest('.al-entry-row[data-toggle-id]'); + if (row) { + e.preventDefault(); + const entryId = row.dataset.toggleId ?? ''; + if (entryId) activityLogToggleDetail(entryId); + } + }); +} + +// ─── Full panel render ─────────────────────────────────────── + +function _render(): void { + const panel = document.getElementById('tab-activity_log'); + if (!panel) return; + + panel.innerHTML = `
+ ${_renderFilterToolbar()} +
+ ${_renderList()} +
+
`; + + _attachDelegatedClicks(); +} + +// ─── Partial re-render helpers ─────────────────────────────── + +function _updateListContainer(): void { + const container = document.getElementById('al-list-container'); + if (!container) return; + container.innerHTML = _renderList(); +} + +// ─── Data fetching ─────────────────────────────────────────── + +/** Surface a loading affordance only when a request is slow enough to notice. */ +function _showDelayedBusy(): void { + if (!_loading) return; + if (_entries.length === 0) { + // Nothing to keep on screen — fall back to the full spinner. + _showSpinner = true; + _updateListContainer(); + } else { + // Re-query of a populated list: keep the current rows, just dim them. + const c = document.getElementById('al-list-container'); + c?.classList.add('al-busy'); + c?.setAttribute('aria-busy', 'true'); + } +} + +/** Clear all loading affordances (timer, spinner flag, busy dim). Idempotent. */ +function _clearBusy(): void { + if (_loadingDelayTimer) { + clearTimeout(_loadingDelayTimer); + _loadingDelayTimer = null; + } + _showSpinner = false; + const c = document.getElementById('al-list-container'); + c?.classList.remove('al-busy'); + c?.removeAttribute('aria-busy'); +} + +async function _fetchPage(beforeSeq: number | null = null, append = false): Promise { + if (_loading) return; + _loading = true; + if (!append) { + // Reset the cursor for a fresh query, but DON'T clear `_entries` — keep + // the current rows on screen so filtering an already-populated list + // never flashes the full "Loading" state (the new results replace them + // on arrival). + _nextBeforeSeq = null; + _hasMore = false; + } + + if (!_hasLoadedOnce && !append) { + // Genuine first load — there's nothing to show yet, so the spinner is + // the correct (and expected) initial state. Show it immediately. + _showSpinner = true; + _updateListContainer(); + } else if (!append) { + // Re-query (filter change / language change): defer any loading hint so + // near-instant responses show nothing at all; a slow request gets a + // subtle dim after the delay. + if (_loadingDelayTimer) clearTimeout(_loadingDelayTimer); + _loadingDelayTimer = setTimeout(_showDelayedBusy, 180); + } + // append (load-more): keep existing rows, no loading indicator. + + try { + const qs = _buildQuery(beforeSeq); + const res = await fetchWithAuth(`/activity-log${qs}`); + if (!res || !res.ok) { + throw new Error(`HTTP ${res?.status}`); + } + const page: ActivityPage = await res.json(); + // API returns each page oldest-first within the page; reverse to newest-first + // so the in-memory list is newest at index 0 (top of the rendered log). + const pageEntries = [...page.entries].reverse(); + if (append) { + _entries = [..._entries, ...pageEntries]; + } else { + _entries = pageEntries; + } + _nextBeforeSeq = page.next_before_seq; + _hasMore = page.has_more; + _total = page.total; + _hasLoadedOnce = true; + // Clear loading affordances BEFORE rendering so a zero-result page + // renders the empty state (not the spinner) and a re-query swaps in the + // fresh, undimmed rows. + _clearBusy(); + _loading = false; + _updateListContainer(); + } catch (e: unknown) { + if (e && typeof e === 'object' && 'isAuth' in e) return; + _clearBusy(); + const container = document.getElementById('al-list-container'); + if (container) { + container.innerHTML = ``; + } + } finally { + _loading = false; + _clearBusy(); + } +} + +// ─── Filter re-query with debounce for text fields ─────────── + +function _requery(debounce = false): void { + if (debounce) { + if (_debounceTimer) clearTimeout(_debounceTimer); + _debounceTimer = setTimeout(() => { _fetchPage(null, false); }, 350); + } else { + _fetchPage(null, false); + } +} + +// ─── Live event handling ────────────────────────────────────── + +function _entryPassesFilters(entry: ActivityEntry): boolean { + if (_filters.categories.length && !_filters.categories.includes(entry.category)) return false; + if (_filters.severities.length && !_filters.severities.includes(entry.severity)) return false; + if (_filters.actor && entry.actor !== _filters.actor) return false; + if (_filters.entity_type && entry.entity_type !== _filters.entity_type) return false; + if (_filters.q) { + const q = _filters.q.toLowerCase(); + if (!entry.message.toLowerCase().includes(q) && + !entry.action.toLowerCase().includes(q) && + !entry.actor.toLowerCase().includes(q)) return false; + } + // Date range filters: if an entry is brand-new it passes "since" checks trivially + if (_filters.since) { + const sinceMs = new Date(_filters.since).getTime(); + if (!isNaN(sinceMs) && new Date(entry.ts).getTime() < sinceMs) return false; + } + if (_filters.until) { + const untilMs = new Date(_filters.until).getTime(); + if (!isNaN(untilMs) && new Date(entry.ts).getTime() > untilMs) return false; + } + return true; +} + +function _prependLiveEntry(entry: ActivityEntry): void { + if (!_entryPassesFilters(entry)) return; + + _entries = [entry, ..._entries]; + _total = _total + 1; + + // Prepend the row into the existing list (no full re-render for performance) + const list = document.getElementById('tab-activity_log')?.querySelector('.al-list'); + if (list) { + const html = _renderEntryRow(entry, true); + list.insertAdjacentHTML('afterbegin', html); + // Animate the new entry + const firstRow = list.firstElementChild as HTMLElement | null; + if (firstRow) { + requestAnimationFrame(() => { firstRow.classList.add('al-entry-appear'); }); + } + // Update count badge + const countEl = list.closest('.al-panel')?.querySelector('.al-count'); + if (countEl) countEl.textContent = t('activity_log.n_entries', { n: _total }); + } else { + _updateListContainer(); + } +} + +function _startLiveUpdates(): void { + if (_liveEventListener) return; + _liveEventListener = (e: Event) => { + const ce = e as CustomEvent; + const entry = ce.detail?.entry as ActivityEntry | undefined; + if (!entry) return; + _prependLiveEntry(entry); + }; + document.addEventListener('server:activity_logged', _liveEventListener); +} + +// ─── Public window-exposed interaction functions ────────────── + +export function activityLogToggleDetail(entryId: string): void { + if (_expandedIds.has(entryId)) { + _expandedIds.delete(entryId); + } else { + _expandedIds.add(entryId); + } + // Update just the affected row + const panel = document.getElementById('tab-activity_log'); + if (!panel) return; + const row = panel.querySelector(`[data-al-id="${CSS.escape(entryId)}"]`); + if (!row) return; + const entry = _entries.find(e => e.id === entryId); + if (!entry) return; + row.outerHTML = _renderEntryRow(entry, false); +} + +export function activityLogToggleCat(cat: string): void { + const idx = _filters.categories.indexOf(cat); + if (idx >= 0) { + _filters.categories = _filters.categories.filter(c => c !== cat); + } else { + _filters.categories = [..._filters.categories, cat]; + } + _render(); + _requery(); +} + +export function activityLogToggleSev(sev: string): void { + const idx = _filters.severities.indexOf(sev); + if (idx >= 0) { + _filters.severities = _filters.severities.filter(s => s !== sev); + } else { + _filters.severities = [..._filters.severities, sev]; + } + _render(); + _requery(); +} + +export function activityLogOnSearch(val: string): void { + _filters.q = val; + _requery(true); +} + +export function activityLogOnActor(val: string): void { + _filters.actor = val.trim(); + _requery(true); +} + +export function activityLogOnEntityType(val: string): void { + _filters.entity_type = val.trim(); + _requery(true); +} + +export function activityLogOnSince(val: string): void { + _filters.since = val; + _requery(); +} + +export function activityLogOnUntil(val: string): void { + _filters.until = val; + _requery(); +} + +export function activityLogClearFilters(): void { + _filters.categories = []; + _filters.severities = []; + _filters.actor = ''; + _filters.entity_type = ''; + _filters.since = ''; + _filters.until = ''; + _filters.q = ''; + _render(); + _requery(); +} + +export function activityLogPreset(key: string): void { + // Reset all filters first + _filters.categories = []; + _filters.severities = []; + _filters.actor = ''; + _filters.entity_type = ''; + _filters.q = ''; + _filters.until = ''; + + switch (key) { + case 'today': { + const todayStart = new Date(); + todayStart.setHours(0, 0, 0, 0); + // datetime-local format: YYYY-MM-DDTHH:MM + _filters.since = todayStart.toISOString().slice(0, 16); + break; + } + case 'errors': + _filters.severities = ['error']; + _filters.since = ''; + break; + case 'auth': + _filters.categories = ['auth']; + _filters.since = ''; + break; + case 'devices': + _filters.categories = ['device']; + _filters.since = ''; + break; + } + _render(); + _requery(); +} + +export function activityLogLoadMore(): void { + if (_hasMore && !_loading) { + _fetchPage(_nextBeforeSeq, true); + } +} + +export async function activityLogExport(format: 'csv' | 'json'): Promise { + try { + showToast(t('activity_log.export.downloading'), 'info'); + const qs = _buildQuery(null); + const sep = qs ? '&' : '?'; + const url = `/activity-log/export${qs}${sep}format=${format}`; + const res = await fetchWithAuth(url); + if (!res || !res.ok) throw new Error(`HTTP ${res?.status}`); + + const blob = await res.blob(); + const blobUrl = URL.createObjectURL(blob); + const now = new Date().toISOString().slice(0, 19).replace(/:/g, '-'); + const filename = `ledgrab-activity-${now}.${format}`; + const a = document.createElement('a'); + a.href = blobUrl; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + setTimeout(() => URL.revokeObjectURL(blobUrl), 10000); + } catch (e: unknown) { + if (e && typeof e === 'object' && 'isAuth' in e) return; + showToast(t('activity_log.export.error'), 'error'); + } +} + +export function activityLogNavigateToEntity(entityType: string, entityId: string): void { + const nav = _ENTITY_NAV[entityType]; + if (!nav || !entityId) return; + navigateToCard(nav.tab, nav.subTab, null, nav.attr, entityId); +} + +// ─── Public helpers for Phase 6 (Dashboard widget + Settings export) ──────── + +/** + * Fetch the N most-recent activity log entries without affecting the full-tab + * state (separate request, no state mutations). + */ +export async function fetchRecentEntries(limit = 5): Promise { + try { + const res = await fetchWithAuth(`/activity-log?limit=${limit}`); + if (!res || !res.ok) return []; + const page: ActivityPage = await res.json(); + // API returns oldest-first within page; reverse for newest-first. + return [...page.entries].reverse(); + } catch { + return []; + } +} + +/** + * Render a compact single-line entry row for the Dashboard widget. + * Re-uses the severity icon / class helpers and escapeHtml from the full viewer + * so the visual language is consistent. + */ +export function renderCompactEntry(entry: ActivityEntry): string { + const relTime = formatRelativeTime(entry.ts); + const sevIcon = _severityIcon(entry.severity); + const sevClass = _severityClass(entry.severity); + return `
+ ${sevIcon} + ${escapeHtml(relTime)} + ${escapeHtml(localizeMessage(entry))} +
`; +} + +// ─── Main loader (registered with tab-registry) ───────────── + +export async function loadActivityLog(): Promise { + const panel = document.getElementById('tab-activity_log'); + if (!panel) return; + + _initialized = true; + _render(); + await _fetchPage(null, false); + _startLiveUpdates(); + ensureRelativeTimeTicker(); + + // Re-render on language change (baked-in t() calls) + document.addEventListener('languageChanged', _onLanguageChanged); +} + +function _onLanguageChanged(): void { + if (!_initialized) return; + _render(); + _fetchPage(null, false); +} diff --git a/server/src/ledgrab/static/js/features/auto-calibration.ts b/server/src/ledgrab/static/js/features/auto-calibration.ts index ca583c8..d64a908 100644 --- a/server/src/ledgrab/static/js/features/auto-calibration.ts +++ b/server/src/ledgrab/static/js/features/auto-calibration.ts @@ -98,6 +98,9 @@ export interface AutoCalOptions { let _state: AutoCalState | null = null; let _opts: AutoCalOptions | null = null; let _deviceEntitySelect: EntitySelect | null = null; +/** First render after mount: let the containing Modal's autofocus win; only + * steal focus on subsequent step transitions (for D-pad / TV navigation). */ +let _firstRender = true; // ── Public API ───────────────────────────────────────────────────────────── @@ -119,6 +122,7 @@ let _deviceEntitySelect: EntitySelect | null = null; */ export async function mountAutoCalibration(opts: AutoCalOptions): Promise { await unmountAutoCalibration(); + _firstRender = true; _opts = opts; let cssSourceType = 'picture'; @@ -177,6 +181,27 @@ function _render(): void { case 'corners': _renderCorners(); break; case 'preview': _renderPreview(); break; } + _focusFirstControl(); +} + +/** + * Move focus to the step's first focusable control after a re-render so D-pad / + * TV WebView users don't land on a node that was just removed. Skipped on the + * very first render so it doesn't fight the Modal's own initial autofocus. + */ +function _focusFirstControl(): void { + if (_firstRender) { + _firstRender = false; + return; + } + const container = _opts?.container; + if (!container) return; + requestAnimationFrame(() => { + const el = container.querySelector( + 'button:not([disabled]),[href],input:not([disabled]),select:not([disabled]),textarea:not([disabled])' + ) as HTMLElement | null; + if (el && el.offsetParent !== null) el.focus(); + }); } // ── Step 1: Device picker ────────────────────────────────────────────────── @@ -309,10 +334,15 @@ export async function autoCalSetCorner(position: StartPosition): Promise { // LED is at index 0; advance to ~5% to show movement direction await _setPosition(0); await _delay(350); + // User may have cancelled during the delay (unmount sets _state = null). + if (!_state) return; const advance = Math.max(4, Math.round(_state.ledCount * 0.04)); await _setPosition(advance); + if (!_state) return; _state.busy = false; } catch (err: unknown) { + // _state may have been torn down mid-await by a cancel. + if (!_state) return; _state.busy = false; _state.errorMsg = _errMsg(err); _state.step = 'corner'; // revert on error diff --git a/server/src/ledgrab/static/js/features/calibration.ts b/server/src/ledgrab/static/js/features/calibration.ts index 63545d3..d9d578c 100644 --- a/server/src/ledgrab/static/js/features/calibration.ts +++ b/server/src/ledgrab/static/js/features/calibration.ts @@ -64,6 +64,14 @@ class CalibrationModal extends Modal { if (testGroup) testGroup.style.display = 'none'; const testSection = document.getElementById('calibration-test-setup-section'); if (testSection) testSection.style.display = 'none'; + // Destroy the test-device EntitySelect; otherwise the instance leaks and + // leaves a stale enhanced trigger button in the DOM until the next open. + if (_calTestDeviceEntitySelect) { + _calTestDeviceEntitySelect.destroy(); + _calTestDeviceEntitySelect = null; + } + const testDeviceSelect = document.getElementById('calibration-test-device') as HTMLSelectElement | null; + if (testDeviceSelect) testDeviceSelect.onchange = null; } else { const deviceId = (this.$('calibration-device-id') as HTMLInputElement).value; if (deviceId) clearTestMode(deviceId); diff --git a/server/src/ledgrab/static/js/features/color-strips/test.ts b/server/src/ledgrab/static/js/features/color-strips/test.ts index aca20c0..23529b0 100644 --- a/server/src/ledgrab/static/js/features/color-strips/test.ts +++ b/server/src/ledgrab/static/js/features/color-strips/test.ts @@ -151,6 +151,15 @@ let _cssTestWs: WebSocket | null = null; let _cssTestRaf: number | null = null; let _cssTestLatestRgb: Uint8Array | null = null; let _cssTestMeta: any = null; +// Set true when a WS frame updates the latest RGB / layer data; the render loop +// only repaints when a new frame has arrived, then clears it. Avoids re-rendering +// the same frame every RAF tick on low-powered devices (e.g. Android TV WebView). +let _cssTestFrameDirty: boolean = false; +// Per-canvas render caches: reuse the 2d context and a single ImageData buffer +// instead of calling getContext()/createImageData() and resetting width/height +// every frame. ImageData is rebuilt only when the canvas LED count changes. +const _cssTestCtxCache = new WeakMap(); +const _cssTestImageDataCache = new WeakMap(); let _cssTestSourceId: string | null = null; let _cssTestIsComposite: boolean = false; let _cssTestLayerData: any = null; // { layerCount, ledCount, layers: [Uint8Array], composite: Uint8Array } @@ -234,6 +243,7 @@ function _testKeyColorsSource(sourceId: string) { _cssTestLatestRgb = null; _cssTestMeta = null; _cssTestLayerData = null; + _cssTestFrameDirty = false; const modal = document.getElementById('test-css-source-modal') as HTMLElement | null; if (!modal) return; @@ -414,6 +424,7 @@ function _openTestModal(sourceId: string) { _cssTestIsComposite = false; _cssTestIsKeyColors = false; _cssTestLayerData = null; + _cssTestFrameDirty = false; const modal = document.getElementById('test-css-source-modal') as HTMLElement | null; if (!modal) return; @@ -636,6 +647,8 @@ function _cssTestConnect(sourceId: string, ledCount: number, fps?: number) { // Standard format: raw RGB _cssTestLatestRgb = raw; } + // Mark a new frame so the render loop repaints on the next RAF tick. + _cssTestFrameDirty = true; // Track FPS for api_input sources if (_cssTestIsApiInput) { @@ -756,6 +769,7 @@ export function applyCssTestSettings() { _cssTestLatestRgb = null; _cssTestMeta = null; _cssTestLayerData = null; + _cssTestFrameDirty = false; // Read selected input source from selector (both CSS and CSPT modes) const inputSel = document.getElementById('css-test-cspt-input-select') as HTMLSelectElement | null; @@ -774,6 +788,9 @@ export function applyCssTestSettings() { function _cssTestRenderLoop() { _cssTestRaf = requestAnimationFrame(_cssTestRenderLoop); if (!_cssTestMeta) return; + // Skip repaint when no new WS frame has arrived since the last render. + if (!_cssTestFrameDirty) return; + _cssTestFrameDirty = false; const isPicture = _cssTestMeta.edges && _cssTestMeta.edges.length > 0; @@ -786,14 +803,40 @@ function _cssTestRenderLoop() { } } +// Returns a cached 2d context and a reusable ImageData for the canvas, only +// resizing the canvas / rebuilding the ImageData when the requested dimensions +// differ from the cached ones. Returns null if a 2d context is unavailable. +function _cssTestAcquireCanvas( + canvas: HTMLCanvasElement, + w: number, + h: number, +): { ctx: CanvasRenderingContext2D; imageData: ImageData } | null { + let ctx = _cssTestCtxCache.get(canvas); + if (!ctx) { + const got = canvas.getContext('2d'); + if (!got) return null; + ctx = got; + _cssTestCtxCache.set(canvas, ctx); + } + // Only touch width/height when they actually change (resetting them clears + // the backing store and is expensive). + if (canvas.width !== w) canvas.width = w; + if (canvas.height !== h) canvas.height = h; + let cached = _cssTestImageDataCache.get(canvas); + if (!cached || cached.w !== w || cached.h !== h) { + cached = { w, h, imageData: ctx.createImageData(w, h) }; + _cssTestImageDataCache.set(canvas, cached); + } + return { ctx, imageData: cached.imageData }; +} + function _cssTestRenderStrip(rgbBytes: Uint8Array) { const canvas = document.getElementById('css-test-strip-canvas') as HTMLCanvasElement | null; if (!canvas) return; const ledCount = rgbBytes.length / 3; - canvas.width = ledCount; - canvas.height = 1; - const ctx = canvas.getContext('2d')!; - const imageData = ctx.createImageData(ledCount, 1); + const acquired = _cssTestAcquireCanvas(canvas, ledCount, 1); + if (!acquired) return; + const { ctx, imageData } = acquired; const data = imageData.data; for (let i = 0; i < ledCount; i++) { const si = i * 3; @@ -824,10 +867,9 @@ function _cssTestRenderLayers(data: any) { function _cssTestRenderStripCanvas(canvas: HTMLCanvasElement, rgbBytes: Uint8Array) { const ledCount = rgbBytes.length / 3; if (ledCount <= 0) return; - canvas.width = ledCount; - canvas.height = 1; - const ctx = canvas.getContext('2d')!; - const imageData = ctx.createImageData(ledCount, 1); + const acquired = _cssTestAcquireCanvas(canvas, ledCount, 1); + if (!acquired) return; + const { ctx, imageData } = acquired; const data = imageData.data; for (let i = 0; i < ledCount; i++) { const si = i * 3; @@ -852,13 +894,17 @@ function _cssTestRenderRect(rgbBytes: Uint8Array, edges: any[]) { const canvas = document.getElementById(`css-test-edge-${edge}`) as HTMLCanvasElement | null; if (!canvas) continue; const count = indices.length; - if (count === 0) { canvas.width = 0; continue; } + if (count === 0) { + if (canvas.width !== 0) canvas.width = 0; + continue; + } const isH = edge === 'top' || edge === 'bottom'; - canvas.width = isH ? count : 1; - canvas.height = isH ? 1 : count; - const ctx = canvas.getContext('2d')!; - const imageData = ctx.createImageData(canvas.width, canvas.height); + const w = isH ? count : 1; + const h = isH ? 1 : count; + const acquired = _cssTestAcquireCanvas(canvas, w, h); + if (!acquired) continue; + const { ctx, imageData } = acquired; const px = imageData.data; for (let i = 0; i < count; i++) { const si = indices[i] * 3; @@ -1185,6 +1231,7 @@ export function closeTestCssSourceModal() { _cssTestIsComposite = false; _cssTestIsKeyColors = false; _cssTestLayerData = null; + _cssTestFrameDirty = false; _cssTestNotificationIds = []; _cssTestIsApiInput = false; _cssTestStopFpsSampling(); diff --git a/server/src/ledgrab/static/js/features/dashboard-customize.ts b/server/src/ledgrab/static/js/features/dashboard-customize.ts index 316fe68..3b87be5 100644 --- a/server/src/ledgrab/static/js/features/dashboard-customize.ts +++ b/server/src/ledgrab/static/js/features/dashboard-customize.ts @@ -63,6 +63,7 @@ const REORDERABLE_SECTIONS: readonly string[] = [ 'playlists', 'sync-clocks', 'targets', + 'recent-activity', ] as const; const SECTION_LABEL_KEYS: Record = { @@ -73,6 +74,7 @@ const SECTION_LABEL_KEYS: Record = { playlists: 'dashboard.section.playlists', 'sync-clocks': 'dashboard.section.sync_clocks', targets: 'dashboard.section.targets', + 'recent-activity': 'dashboard.section.recent_activity', }; const PERF_CELL_LABEL_KEYS: Record = { diff --git a/server/src/ledgrab/static/js/features/dashboard-layout.ts b/server/src/ledgrab/static/js/features/dashboard-layout.ts index a139b9a..135912d 100644 --- a/server/src/ledgrab/static/js/features/dashboard-layout.ts +++ b/server/src/ledgrab/static/js/features/dashboard-layout.ts @@ -25,6 +25,7 @@ export type SectionKey = | 'playlists' | 'sync-clocks' | 'targets' + | 'recent-activity' // Reserved registry keys for v1.1+ (so saved layouts forward-compat). | 'audio-meters' | 'alerts' @@ -155,6 +156,7 @@ export const DEFAULT_LAYOUT: DashboardLayoutV1 = { _defaultSection('playlists'), _defaultSection('sync-clocks'), _defaultSection('targets'), + _defaultSection('recent-activity'), ], perfCells: [ _defaultPerfCell('patches'), diff --git a/server/src/ledgrab/static/js/features/dashboard.ts b/server/src/ledgrab/static/js/features/dashboard.ts index 18311ae..d21bc21 100644 --- a/server/src/ledgrab/static/js/features/dashboard.ts +++ b/server/src/ledgrab/static/js/features/dashboard.ts @@ -5,7 +5,7 @@ import { apiKey, _dashboardLoading, set_dashboardLoading, dashboardPollInterval, setDashboardPollInterval, colorStripSourcesCache, devicesCache, outputTargetsCache } from '../core/state.ts'; import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, fetchMetricsHistory } from '../core/api.ts'; import { t } from '../core/i18n.ts'; -import { showToast, showConfirm, formatUptime, formatCompact, setTabRefreshing } from '../core/ui.ts'; +import { showToast, showConfirm, formatUptime, formatCompact, setTabRefreshing, ensureRelativeTimeTicker } from '../core/ui.ts'; import { renderPerfSection, renderPerfModeToggle, setPerfMode, initPerfCharts, startPerfPolling, stopPerfPolling, updateActivePatches, updateTotalFps, updateTotalCaptureFps, updateTotalCaptureFpsActual, updateTotalErrors, updateDevices, updateNetworkThroughput, updateDeviceLatency, updateSendTiming, rerenderPerfGrid } from './perf-charts.ts'; import { startAutoRefresh, updateTabBadge } from './tabs.ts'; import { isActiveTab } from '../core/tab-registry.ts'; @@ -21,6 +21,8 @@ import { renderDeviceIconSvg } from '../core/device-icons.ts'; import { createFpsSparkline } from '../core/chart-utils.ts'; import { getOrderedSections, isSectionVisible, getSection, subscribeDashboardLayout, getGlobalConfig } from './dashboard-layout.ts'; import { mountCardModeToggle } from './card-modes.ts'; +import { ICON_ACTIVITY_LOG } from '../core/icons.ts'; +import { fetchRecentEntries, renderCompactEntry, ActivityEntry } from './activity-log.ts'; function _applyGlobalLayoutAttrs(): void { const c = document.getElementById('dashboard-content'); @@ -573,6 +575,84 @@ function renderDashboardPlaylist(playlist: ScenePlaylist): string {
`; } +// ─── Recent Activity widget (Dashboard) ───────────────────── + +const RECENT_ACTIVITY_LIMIT = 5; +let _recentActivityLiveListener: ((e: Event) => void) | null = null; + +/** Fetch recent entries and populate the widget list container. + * Skips the network fetch when the widget already contains live entries + * (dal-list class is set by _renderRecentActivityList on first mount) so + * unrelated dashboard re-renders never re-fetch or flash the widget. */ +async function _loadRecentActivityWidget(): Promise { + const list = document.getElementById('dashboard-recent-activity-list'); + if (!list) return; + + // Widget already populated — only wire up live listener and ticker; + // don't re-fetch or overwrite the live content. + if (list.classList.contains('dal-list')) { + ensureRelativeTimeTicker(); + _startRecentActivityLive(); + return; + } + + const entries = await fetchRecentEntries(RECENT_ACTIVITY_LIMIT); + _renderRecentActivityList(list, entries); + + // Start relative-time ticker (idempotent — shared with the Activity tab) + ensureRelativeTimeTicker(); + + // Start live listener (idempotent) + _startRecentActivityLive(); +} + +function _renderRecentActivityList(list: HTMLElement, entries: ActivityEntry[]): void { + if (!entries || entries.length === 0) { + list.className = ''; + list.innerHTML = `
+ +

${escapeHtml(t('activity_log.empty_no_filters'))}

+
`; + return; + } + list.className = 'dal-list'; + list.innerHTML = entries.map(e => renderCompactEntry(e)).join(''); +} + +function _stopRecentActivityLive(): void { + if (_recentActivityLiveListener) { + document.removeEventListener('server:activity_logged', _recentActivityLiveListener); + _recentActivityLiveListener = null; + } +} + +function _startRecentActivityLive(): void { + // Always tear down first so we never stack listeners across loadDashboard calls. + _stopRecentActivityLive(); + _recentActivityLiveListener = (e: Event) => { + const ce = e as CustomEvent; + const entry = ce.detail?.entry; + if (!entry) return; + const list = document.getElementById('dashboard-recent-activity-list'); + // No-op when the widget isn't mounted (section hidden or not yet rendered). + if (!list) return; + if (!list.classList.contains('dal-list')) { + // Transition from empty-state to list on the first live event + _renderRecentActivityList(list, [entry]); + return; + } + // Surgically prepend the new row and cap at N — no loadDashboard(). + list.insertAdjacentHTML('afterbegin', renderCompactEntry(entry)); + const rows = list.querySelectorAll('.al-compact-row'); + if (rows.length > RECENT_ACTIVITY_LIMIT) { + for (let i = RECENT_ACTIVITY_LIMIT; i < rows.length; i++) { + rows[i].remove(); + } + } + }; + document.addEventListener('server:activity_logged', _recentActivityLiveListener); +} + let _pollDebounce: ReturnType | undefined = undefined; /** Called from the transport-bar poll cycler (and any legacy callers * that might still reference `window.changeDashboardPollInterval`). */ @@ -679,6 +759,127 @@ function _sectionContent(sectionKey: string, itemsHtml: string): string { return ``; } +/** + * Reconcile the `.dashboard-dynamic` container against newly-built HTML + * without a wholesale innerHTML replacement. + * + * Algorithm: + * 1. Parse `newHtml` into a detached container. + * 2. Build a map of existing live sections keyed by data-section. + * 3. For each section in the new HTML (in order): + * a. If the live DOM has that section AND it is content-stable + * (recent-activity with live list) OR its outerHTML hasn't + * changed — keep the live element. + * b. Otherwise replace / insert with the new element. + * 4. Remove sections that no longer appear in the new HTML. + * 5. Re-order to match the new order (move nodes, no recreation). + * + * The `recent-activity` section is treated as content-stable once + * its list has been populated (dal-list class), mirroring the perf- + * persistent pattern. The freshly-built loading placeholder in + * `newHtml` is never compared against the live-entry list — instead + * the live DOM node is always kept when it has real content. + */ +function _reconcileDynamicSections(dynamic: HTMLElement, newHtml: string): void { + // Parse incoming HTML into a scratch container. + const scratch = document.createElement('div'); + scratch.innerHTML = newHtml; + + // Gather incoming sections in order. + const incoming = Array.from( + scratch.querySelectorAll(':scope > .dashboard-section[data-section]') + ); + + // If the new HTML contains non-section top-level nodes (e.g. the + // `.dashboard-no-targets` placeholder shown when there are no entities), + // fall back to a simple innerHTML swap — this path is rare and the + // no-entities state doesn't have live widgets worth preserving. + const totalTopLevel = scratch.children.length; + if (totalTopLevel !== incoming.length) { + if (dynamic.innerHTML !== newHtml) dynamic.innerHTML = newHtml; + return; + } + + // Drop any stray non-section top-level nodes left over from a previous + // state (e.g. the `.dashboard-no-targets` placeholder shown when there + // were zero entities). The reconcile pass below only manages + // `.dashboard-section` children, so without this sweep that orphan node + // would linger over the freshly-populated dashboard. + for (const child of Array.from(dynamic.children)) { + if (!child.matches('.dashboard-section[data-section]')) child.remove(); + } + + // Index live sections by key. + const liveMap = new Map(); + for (const el of Array.from( + dynamic.querySelectorAll(':scope > .dashboard-section[data-section]') + )) { + liveMap.set(el.dataset.section as string, el); + } + + // Collect the keys that should remain (in new order). + const newKeys = new Set(incoming.map(el => el.dataset.section as string)); + + // Remove sections that are no longer present. + for (const [key, el] of liveMap) { + if (!newKeys.has(key)) el.remove(); + } + + // Walk incoming sections in order and reconcile each one. + let insertBefore: HTMLElement | null = null; // node to insert before (null = append) + for (let i = incoming.length - 1; i >= 0; i--) { + const newEl = incoming[i]; + const key = newEl.dataset.section as string; + const live = liveMap.get(key); + + let keep: HTMLElement; + + if (live) { + // Content-stable guard: the recent-activity section must not be + // replaced once it holds live entries — the new HTML only has the + // loading placeholder and would wipe the list. + const isRecentActivity = key === 'recent-activity'; + const raList = isRecentActivity + ? live.querySelector('#dashboard-recent-activity-list') + : null; + const raIsPopulated = raList !== null && raList.classList.contains('dal-list'); + + if (raIsPopulated) { + // Always keep the live recent-activity section as-is. + keep = live; + } else if (live.outerHTML === newEl.outerHTML) { + // Unchanged section — keep live DOM, no mutation. + keep = live; + } else { + // Section content changed — replace. + live.replaceWith(newEl); + liveMap.set(key, newEl); + keep = newEl; + } + } else { + // New section — insert it. + dynamic.appendChild(newEl); // temporary placement; ordering pass below + liveMap.set(key, newEl); + keep = newEl; + } + + // Re-order: after the ordering loop (reverse walk) each `keep` + // should end up just before the node we placed in the previous + // iteration (i+1). Using insertBefore to build correct order. + if (insertBefore === null) { + // Last in order — move to end of dynamic. + if (keep.nextElementSibling !== null || keep.parentElement !== dynamic) { + dynamic.appendChild(keep); + } + } else { + if (keep.nextElementSibling !== insertBefore || keep.parentElement !== dynamic) { + dynamic.insertBefore(keep, insertBefore); + } + } + insertBefore = keep; + } +} + export async function loadDashboard(forceFullRender: boolean = false): Promise { if (_dashboardLoading) return; set_dashboardLoading(true); @@ -763,7 +964,21 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise${t('dashboard.no_targets')}`; + const _raSection = isSectionVisible('recent-activity') ? `
+ ${_sectionHeader('recent-activity', t('dashboard.section.recent_activity'), '')} + ${_sectionContent('recent-activity', `
+
+
+ ${escapeHtml(t('activity_log.loading'))} +
+
+ `)} +
` : ''; + dynamicHtml = `
${t('dashboard.no_targets')}
${_raSection}`; } else { const enriched = targets.map(target => ({ ...target, @@ -1003,6 +1218,23 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise`; } + // Recent Activity section — registered like any other section so it + // participates in layout ordering, show/hide, and Customize panel. + sectionFragments['recent-activity'] = `
+ ${_sectionHeader('recent-activity', t('dashboard.section.recent_activity'), '')} + ${_sectionContent('recent-activity', `
+
+
+ ${escapeHtml(t('activity_log.loading'))} +
+
+ `)} +
`; + // Now assemble in layout-driven order, skipping invisible // sections and the perf section (which is always rendered // separately at the top for chart-persistence reasons). @@ -1043,8 +1275,8 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise { /* widget failure is non-fatal */ }); + } catch (error) { if (error.isAuth) return; console.error('Failed to load dashboard:', error); diff --git a/server/src/ledgrab/static/js/features/settings.ts b/server/src/ledgrab/static/js/features/settings.ts index b62670b..41f101a 100644 --- a/server/src/ledgrab/static/js/features/settings.ts +++ b/server/src/ledgrab/static/js/features/settings.ts @@ -146,6 +146,10 @@ export function switchSettingsTab(tabId: string): void { if (tabId === 'notifications') { initNotificationsPanel(); } + // Lazy-load activity log settings when switching to that tab + if (tabId === 'activity_log') { + loadActivityLogSettings(); + } } // ─── Log Viewer ──────────────────────────────────────────── @@ -526,6 +530,7 @@ export function openSettingsModal(): void { loadLogLevel(); loadShutdownAction(); loadDaylightTimezone(); + loadActivityLogSettings(); _seedRailFooter(); // Refresh the update status so the rail badge ("update available" pill // on the Updates tab) is current when the modal opens — it would @@ -1200,3 +1205,110 @@ export function testNotifFromSettings(): void { fireTestNotification(); } +// ─── Activity Log Settings ────────────────────────────────── + +interface ActivityLogSettingsResponse { + enabled: boolean; + max_days: number; + max_entries: number; +} + +/** Fetch and populate the Activity Log settings panel. */ +export async function loadActivityLogSettings(): Promise { + try { + const res = await fetchWithAuth('/activity-log/settings'); + if (!res || !res.ok) return; + const data: ActivityLogSettingsResponse = await res.json(); + + const enabledCb = document.getElementById('al-settings-enabled') as HTMLInputElement | null; + const maxDaysIn = document.getElementById('al-settings-max-days') as HTMLInputElement | null; + const maxEntriesIn = document.getElementById('al-settings-max-entries') as HTMLInputElement | null; + + if (enabledCb) enabledCb.checked = data.enabled; + if (maxDaysIn) maxDaysIn.value = String(data.max_days); + if (maxEntriesIn) maxEntriesIn.value = String(data.max_entries); + } catch (err) { + // Non-fatal — panel just shows defaults + console.error('Failed to load activity log settings:', err); + } +} + +/** Validate and save the Activity Log retention settings. */ +export async function saveActivityLogSettings(): Promise { + const enabledCb = document.getElementById('al-settings-enabled') as HTMLInputElement | null; + const maxDaysIn = document.getElementById('al-settings-max-days') as HTMLInputElement | null; + const maxEntriesIn = document.getElementById('al-settings-max-entries') as HTMLInputElement | null; + + const enabled = enabledCb ? enabledCb.checked : true; + const maxDays = parseInt(maxDaysIn?.value ?? '365', 10); + const maxEntries = parseInt(maxEntriesIn?.value ?? '100000', 10); + + // Client-side validation matching server bounds + if (isNaN(maxDays) || maxDays < 0 || maxDays > 3650) { + showToast(t('settings.activity_log.error.max_days_range'), 'error'); + return; + } + if (isNaN(maxEntries) || maxEntries < 0 || maxEntries > 10_000_000) { + showToast(t('settings.activity_log.error.max_entries_range'), 'error'); + return; + } + + try { + const res = await fetchWithAuth('/activity-log/settings', { + method: 'PUT', + body: JSON.stringify({ enabled, max_days: maxDays, max_entries: maxEntries }), + }); + if (!res || !res.ok) { + const err = await res?.json().catch(() => ({})); + throw new Error((err as any).detail || `HTTP ${res?.status}`); + } + showToast(t('settings.activity_log.saved'), 'success'); + } catch (err: any) { + if (err?.isAuth) return; + showToast(t('settings.activity_log.save_error') + ': ' + (err?.message || ''), 'error'); + } +} + +/** Export the full activity log as CSV or JSON via authed blob download. */ +export async function activityLogSettingsExport(format: 'csv' | 'json'): Promise { + try { + showToast(t('activity_log.export.downloading'), 'info'); + const res = await fetchWithAuth(`/activity-log/export?format=${format}`); + if (!res || !res.ok) throw new Error(`HTTP ${res?.status}`); + const blob = await res.blob(); + const blobUrl = URL.createObjectURL(blob); + const now = new Date().toISOString().slice(0, 19).replace(/:/g, '-'); + const filename = `ledgrab-activity-${now}.${format}`; + const a = document.createElement('a'); + a.href = blobUrl; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + setTimeout(() => URL.revokeObjectURL(blobUrl), 10000); + } catch (err: any) { + if (err?.isAuth) return; + showToast(t('activity_log.export.error'), 'error'); + } +} + +/** Confirm and clear all activity log entries. */ +export async function clearActivityLog(): Promise { + const ok = await showConfirm(t('settings.activity_log.clear.confirm')); + if (!ok) return; + try { + const res = await fetchWithAuth('/activity-log', { method: 'DELETE', handle401: false }); + if (!res || !res.ok) { + if (res?.status === 401) { + showToast(t('settings.activity_log.clear.auth_required'), 'error'); + return; + } + throw new Error(`HTTP ${res?.status}`); + } + showToast(t('settings.activity_log.clear.success'), 'success'); + } catch (err: any) { + if (err?.isAuth) return; + showToast(t('settings.activity_log.clear.error') + ': ' + (err?.message || ''), 'error'); + } +} + diff --git a/server/src/ledgrab/static/js/features/setup-wizard.ts b/server/src/ledgrab/static/js/features/setup-wizard.ts index a218390..3cc05ea 100644 --- a/server/src/ledgrab/static/js/features/setup-wizard.ts +++ b/server/src/ledgrab/static/js/features/setup-wizard.ts @@ -76,6 +76,9 @@ interface WizardState { let _state: WizardState | null = null; let _modal: SetupWizardModal | null = null; +/** First render after open: let the Modal's own autofocus win; only steal focus + * on subsequent step transitions (for D-pad / TV navigation). */ +let _firstRender = true; const STEPS: WizardStep[] = ['welcome', 'device', 'display', 'scaffold', 'calibrate', 'start', 'done']; @@ -95,6 +98,7 @@ class SetupWizardModal extends Modal { /** Open the wizard (first-run or on-demand). */ export function openSetupWizard(): void { if (!_modal) _modal = new SetupWizardModal(); + _firstRender = true; _state = { step: 'welcome', deviceId: '', @@ -201,8 +205,18 @@ export async function wizardNext(): Promise { _renderStep(); await _runScaffold(); } else if (step === 'calibrate') { - // "Skip calibration" path — move to start - void unmountAutoCalibration(); + // "Skip calibration" path — move to start. + // Mark busy BEFORE the await so a fast second tap on Skip/Next can't + // re-enter wizardNext (gated at the top) and fire a second teardown + + // start during the up-to-10s unmount await. _startOutput() manages + // busy itself afterward. + _state.busy = true; + _renderStep(); + // Await teardown first: unmountAutoCalibration() POSTs session/stop, which + // restores the device's prior target. Starting output before that completes + // would let the stop clobber the just-started target. + await unmountAutoCalibration(); + if (!_state) return; _state.step = 'start'; _renderStep(); await _startOutput(); @@ -361,12 +375,15 @@ async function _loadDisplays(): Promise { } } -export function wizardSelectDisplay(index: number, displayName: string): void { +export function wizardSelectDisplay(index: number, displayName: string, skipRender = false): void { if (!_state) return; _state.displayIndex = index; _state.displayName = displayName; _state.errorMsg = ''; - _renderStep(); + // The manual display-index fallback passes skipRender=true: a full + // _renderStep() rebuilds the container via innerHTML, destroying the input + // and losing focus/caret on every keystroke (unusable on Android TV WebView). + if (!skipRender) _renderStep(); } // ───────────────────────────────────────────────────────────────────────────── @@ -469,6 +486,25 @@ function _renderStep(): void { const html = _buildStepHtml(_state); container.innerHTML = html; _attachStepListeners(_state.step); + _focusFirstControl(container); +} + +/** + * Move focus to the step's first focusable control after a re-render so D-pad / + * TV WebView users don't land on a node that was just removed. Skipped on the + * very first render so it doesn't fight the Modal's own initial autofocus. + */ +function _focusFirstControl(container: HTMLElement): void { + if (_firstRender) { + _firstRender = false; + return; + } + requestAnimationFrame(() => { + const el = container.querySelector( + 'button:not([disabled]),[href],input:not([disabled]),select:not([disabled]),textarea:not([disabled])' + ) as HTMLElement | null; + if (el && el.offsetParent !== null) el.focus(); + }); } function _renderProgressBar(): void { @@ -654,7 +690,7 @@ function _buildDisplayStep(state: WizardState): string { + oninput="wizardSelectDisplay(parseInt(this.value)||0, 'Display '+this.value, true)"> `; } else { diff --git a/server/src/ledgrab/static/js/features/tutorials.ts b/server/src/ledgrab/static/js/features/tutorials.ts index 117ad71..6d13316 100644 --- a/server/src/ledgrab/static/js/features/tutorials.ts +++ b/server/src/ledgrab/static/js/features/tutorials.ts @@ -66,6 +66,7 @@ const gettingStartedSteps: TutorialStep[] = [ { selector: '#tab-btn-streams', textKey: 'tour.sources', position: 'bottom' }, { selector: '#tab-btn-integrations', textKey: 'tour.integrations', position: 'bottom' }, { selector: '#tab-btn-graph', textKey: 'tour.graph', position: 'bottom' }, + { selector: '#tab-btn-activity_log', textKey: 'tour.activity_log', position: 'bottom' }, { selector: 'a.header-link[href="/docs"]', textKey: 'tour.api', position: 'bottom' }, { selector: '[onclick*="openCommandPalette"]', textKey: 'tour.search', position: 'bottom' }, { selector: '[onclick*="toggleTheme"]', textKey: 'tour.theme', position: 'bottom' }, diff --git a/server/src/ledgrab/static/js/global.d.ts b/server/src/ledgrab/static/js/global.d.ts index 4ff0677..a34c8db 100644 --- a/server/src/ledgrab/static/js/global.d.ts +++ b/server/src/ledgrab/static/js/global.d.ts @@ -457,6 +457,26 @@ startTargetOverlay: (...args: any[]) => any; applyBgEffect: (id: string) => void; renderAppearanceTab: () => void; + // ─── Activity Log ─── + loadActivityLog: () => Promise; + activityLogToggleDetail: (entryId: string) => void; + loadActivityLogSettings: () => Promise; + saveActivityLogSettings: () => Promise; + activityLogSettingsExport: (format: 'csv' | 'json') => Promise; + clearActivityLog: () => Promise; + activityLogToggleCat: (cat: string) => void; + activityLogToggleSev: (sev: string) => void; + activityLogOnSearch: (val: string) => void; + activityLogOnActor: (val: string) => void; + activityLogOnEntityType: (val: string) => void; + activityLogOnSince: (val: string) => void; + activityLogOnUntil: (val: string) => void; + activityLogClearFilters: () => void; + activityLogPreset: (key: string) => void; + activityLogLoadMore: () => void; + activityLogExport: (format: 'csv' | 'json') => Promise; + activityLogNavigateToEntity: (entityType: string, entityId: string) => void; + // ─── Overlay spinner internals ─── overlaySpinnerTimer: ReturnType | null; _overlayEscHandler: ((e: KeyboardEvent) => void) | null; diff --git a/server/src/ledgrab/static/locales/en.json b/server/src/ledgrab/static/locales/en.json index 33d6e27..0b48d2a 100644 --- a/server/src/ledgrab/static/locales/en.json +++ b/server/src/ledgrab/static/locales/en.json @@ -1,3308 +1,3464 @@ { - "app.title": "LED Grab", - "bindable.none": "None (static value)", - "bindable.toggle": "Toggle value source binding", - "app.version": "Version:", - "app.api_docs": "API Documentation", - "app.connection_lost": "Server unreachable", - "app.connection_retrying": "Attempting to reconnect…", - "app.server_restarting": "Server restarting…", - "app.server_restarting_sub": "Please wait, the server will be back shortly.", - "demo.badge": "DEMO", - "demo.banner": "You're in demo mode — all devices and data are virtual. No real hardware is used.", - "theme.toggle": "Toggle theme", - "bg.anim.toggle": "Toggle ambient background", - "accent.title": "Accent color", - "accent.custom": "Custom", - "accent.reset": "Reset", - "locale.change": "Change language", - "auth.login": "Login", - "auth.logout": "Logout", - "auth.authenticated": "● Authenticated", - "auth.title": "Login to LED Grab", - "auth.message": "Please enter your API key to authenticate and access the LED Grab.", - "auth.label": "API Key:", - "auth.placeholder": "Enter your API key...", - "auth.hint": "Your API key will be stored securely in your browser's local storage.", - "auth.button.cancel": "Cancel", - "auth.button.login": "Login", - "auth.error.required": "Please enter an API key", - "auth.error.invalid": "Invalid API key. Please try again.", - "auth.success": "Logged in successfully!", - "auth.logout.confirm": "Are you sure you want to logout?", - "auth.logout.success": "Logged out successfully", - "auth.please_login": "Please login to view", - "auth.session_expired": "Your session has expired or the API key is invalid. Please login again.", - "auth.prompt_update": "Current API key is set. Enter new key to update or leave blank to remove:", - "auth.prompt_enter": "Enter your API key:", - "auth.toggle_password": "Toggle password visibility", - "api_key.login": "Login", - "setup.title": "Server setup required", - "setup.description": "This LedGrab server has no API keys configured, so access from other devices on the network is disabled for security. Configure a key on the machine running the server to enable LAN access.", - "setup.step1_label": "1. On the server machine, edit config/default_config.yaml:", - "setup.step2_label": "2. Restart the server, then reload this page and log in with that key.", - "setup.step3_label": "Alternative: open LedGrab from the server machine itself (loopback), no key required:", - "setup.hint_openssl": "Generate a strong key on Linux/macOS with openssl rand -hex 32, or on Windows PowerShell with [guid]::NewGuid().ToString('N') + [guid]::NewGuid().ToString('N').", - "setup.copy": "Copy snippet", - "setup.copied": "Copied to clipboard", - "setup.retry": "I've configured a key — retry", - "setup.still_required": "Server still reports no API keys. Make sure you saved the config file and restarted the server.", - "displays.title": "Available Displays", - "displays.layout": "Displays", - "displays.information": "Display Information", - "displays.legend.primary": "Primary Display", - "displays.legend.secondary": "Secondary Display", - "displays.badge.primary": "Primary", - "displays.badge.secondary": "Secondary", - "displays.resolution": "Resolution:", - "displays.refresh_rate": "Refresh Rate:", - "displays.position": "Position:", - "displays.index": "Display Index:", - "displays.loading": "Loading displays...", - "displays.none": "No displays available", - "displays.failed": "Failed to load displays", - "displays.picker.title": "Select a Display", - "displays.picker.title.lead": "Select", - "displays.picker.title.accent": "Display", - "displays.picker.eyebrow.label": "Source", - "displays.picker.eyebrow.channel": "Display · Map", - "displays.picker.eyebrow.channel.device": "Device · List", - "displays.picker.foot.dismiss": "dismiss", - "displays.picker.foot.select": "select monitor", - "displays.picker.foot.select.device": "select device", - "displays.picker.select": "Select display...", - "displays.picker.click_to_select": "Click to select this display", - "displays.picker.adb_connect": "Connect ADB device", - "displays.picker.adb_connect.placeholder": "IP address (e.g. 192.168.2.201)", - "displays.picker.adb_connect.button": "Connect", - "displays.picker.adb_connect.success": "Device connected", - "displays.picker.adb_connect.error": "Failed to connect device", - "displays.picker.adb_disconnect": "Disconnect", - "displays.picker.no_android": "No Android devices found. Connect via USB or enter IP above.", - "templates.title": "Engine Templates", - "templates.description": "Capture templates define how the screen is captured. Each template uses a specific capture engine (MSS, DXcam, WGC) with custom settings. Assign templates to devices for optimal performance.", - "templates.loading": "Loading templates...", - "templates.empty": "No capture templates configured", - "templates.add": "Add Engine Template", - "templates.edit": "Edit Engine Template", - "templates.name": "Template Name:", - "templates.name.placeholder": "My Custom Template", - "templates.description.label": "Description (optional):", - "templates.description.placeholder": "Describe this template...", - "templates.engine": "Capture Engine:", - "templates.engine.hint": "Select the screen capture technology to use", - "templates.engine.select": "Select an engine...", - "templates.engine.unavailable": "Unavailable", - "templates.engine.unavailable.hint": "This engine is not available on your system", - "templates.engine.mss.desc": "Cross-platform, pure Python", - "templates.engine.dxcam.desc": "DirectX, low latency", - "templates.engine.bettercam.desc": "DirectX, high performance", - "templates.engine.camera.desc": "USB/IP camera capture", - "templates.engine.scrcpy.desc": "Android screen mirror (adb screencap)", - "templates.engine.scrcpy_client.desc": "Android H.264 stream, high FPS", - "templates.engine.wgc.desc": "Windows Graphics Capture", - "templates.engine.demo.desc": "Animated test pattern (demo mode)", - "templates.engine.mediaprojection.desc": "Native Android screen capture", - "templates.engine.android_camera.desc": "On-device camera capture (Camera2)", - "templates.config": "Configuration", - "templates.config.show": "Show configuration", - "templates.config.none": "No additional configuration", - "templates.config.default": "Default", - "templates.config.camera_backend.auto": "Auto-detect best backend", - "templates.config.camera_backend.dshow": "Windows DirectShow", - "templates.config.camera_backend.msmf": "Windows Media Foundation", - "templates.config.camera_backend.v4l2": "Linux Video4Linux2", - "templates.config.resolution.auto": "Open at the camera's max supported mode", - "templates.config.resolution.480p": "VGA — lowest CPU and bandwidth", - "templates.config.resolution.720p": "HD", - "templates.config.resolution.1080p": "Full HD", - "templates.config.resolution.1440p": "QHD / 2K", - "templates.config.resolution.2160p": "4K UHD — highest CPU and bandwidth", - "templates.created": "Template created successfully", - "templates.updated": "Template updated successfully", - "templates.deleted": "Template deleted successfully", - "templates.delete.confirm": "Are you sure you want to delete this template?", - "templates.error.load": "Failed to load templates", - "templates.error.engines": "Failed to load engines", - "templates.error.required": "Please fill in all required fields", - "templates.error.delete": "Failed to delete template", - "templates.error.save_failed": "Failed to save template", - "templates.error.load_failed": "Failed to load template", - "templates.test.title": "Test Capture", - "templates.test.description": "Test this template before saving to see a capture preview and performance metrics.", - "templates.test.display": "Display:", - "templates.test.display.select": "Select display...", - "templates.test.device": "Device:", - "templates.test.device.select": "Select device...", - "templates.test.duration": "Capture Duration (s):", - "templates.test.border_width": "Border Width (px):", - "templates.test.run": "Run", - "templates.test.running": "Running test...", - "templates.test.results.preview": "Full Capture Preview", - "templates.test.results.borders": "Border Extraction", - "templates.test.results.top": "Top", - "templates.test.results.right": "Right", - "templates.test.results.bottom": "Bottom", - "templates.test.results.left": "Left", - "templates.test.results.performance": "Performance", - "templates.test.results.capture_time": "Capture", - "templates.test.results.extraction_time": "Extraction", - "templates.test.results.total_time": "Total", - "templates.test.results.max_fps": "Max FPS", - "templates.test.results.duration": "Duration", - "templates.test.results.frame_count": "Frames", - "templates.test.results.actual_fps": "Actual FPS", - "templates.test.results.avg_capture_time": "Avg Capture", - "templates.test.results.resolution": "Resolution:", - "templates.test.error.no_engine": "Please select a capture engine", - "templates.test.error.no_display": "Please select a display", - "templates.test.error.no_device": "Please select a device", - "templates.test.error.failed": "Test failed", - "devices.title": "Devices", - "device.select_type": "Select Device Type", - "devices.add": "Add New Device", - "devices.loading": "Loading devices...", - "devices.none": "No devices configured", - "devices.failed": "Failed to load devices", - "devices.wled_config": "WLED Configuration:", - "devices.wled_note": "Configure your WLED device (effects, segments, color order, power limits, etc.) using the", - "devices.wled_link": "official WLED app", - "devices.wled_note_or": "or the built-in", - "devices.wled_webui_link": "WLED Web UI", - "devices.wled_note_webui": "(open your device's IP in a browser).", - "devices.wled_note2": "This controller sends pixel color data and controls brightness per device.", - "device.scan": "Auto Discovery", - "device.scan.empty": "No devices found", - "device.scan.error": "Network scan failed", - "device.scan.already_added": "Already added", - "device.scan.selected": "Device selected", - "device.type": "Device Type:", - "device.type.hint": "Select the type of LED controller", - "device.type.wled": "WLED", - "device.type.wled.desc": "WiFi LED controller over HTTP/UDP", - "device.type.adalight": "Adalight", - "device.type.adalight.desc": "Serial LED protocol for Arduino", - "device.type.ambiled": "AmbiLED", - "device.type.ambiled.desc": "Serial protocol for AmbiLED devices", - "device.type.mqtt": "MQTT", - "device.type.mqtt.desc": "Publish LED data via MQTT broker", - "device.type.ws": "WebSocket", - "device.type.ws.desc": "Stream LED data to WebSocket clients", - "device.type.openrgb": "OpenRGB", - "device.type.openrgb.desc": "Control RGB peripherals via OpenRGB", - "device.type.dmx": "DMX", - "device.type.dmx.desc": "Art-Net / sACN (E1.31) stage lighting", - "device.type.ddp": "DDP", - "device.type.ddp.desc": "Direct UDP pixel push (Pixelblaze, ESPixelStick, Falcon)", - "device.type.opc": "OPC", - "device.type.opc.desc": "Open Pixel Control (Fadecandy, xLights, hobbyist drivers)", - "device.type.mock": "Mock", - "device.type.mock.desc": "Virtual device for testing", - "device.type.espnow": "ESP-NOW", - "device.type.espnow.desc": "Ultra-low-latency via ESP32 gateway", - "device.type.hue": "Philips Hue", - "device.type.hue.desc": "Hue Entertainment API streaming", - "device.type.yeelight": "Yeelight", - "device.type.yeelight.desc": "Xiaomi smart bulb / lightstrip via LAN (single color, averaged from the strip)", - "device.yeelight.url": "IP Address:", - "device.yeelight.url.hint": "LAN IP of the Yeelight bulb. TCP port 55443 is fixed in the protocol.", - "device.yeelight.url.placeholder": "192.168.1.50", - "device.yeelight_min_interval": "Min Update Interval:", - "device.yeelight_min_interval.hint": "Client-side rate limit between commands in ms. Default 500 ms keeps bulbs under their ~1 cmd/sec cap; lower values risk throttling.", - "device.type.wiz": "WiZ", - "device.type.wiz.desc": "WiZ Connected (Philips) UDP LAN bulb", - "device.wiz.url": "IP Address:", - "device.wiz.url.hint": "LAN IP of the WiZ bulb. UDP port 38899 is the protocol default.", - "device.wiz.url.placeholder": "192.168.1.50", - "device.wiz_min_interval": "Min Update Interval:", - "device.wiz_min_interval.hint": "Client-side rate limit between commands in ms. UDP fire-and-forget tolerates fast updates; default 50 ms ≈ 20 Hz.", - "device.type.lifx": "LIFX", - "device.type.lifx.desc": "LIFX smart bulb / lightstrip over LAN", - "device.lifx.url": "IP Address:", - "device.lifx.url.hint": "LAN IP of the LIFX bulb. UDP port 56700 is the protocol default.", - "device.lifx.url.placeholder": "192.168.1.50", - "device.lifx_min_interval": "Min Update Interval:", - "device.lifx_min_interval.hint": "Client-side rate limit between commands in ms. LIFX recommends ≤20 cmd/sec; default 50 ms matches that ceiling.", - "device.type.govee": "Govee", - "device.type.govee.desc": "Govee Wi-Fi bulb / ambient kit via LAN API", - "device.govee.url": "IP Address:", - "device.govee.url.hint": "LAN IP of the Govee device. Enable LAN Control in the Govee Home app first (Device → ⚙ → LAN Control), or the bulb won’t respond.", - "device.govee.url.placeholder": "192.168.1.50", - "device.govee_min_interval": "Min Update Interval:", - "device.govee_min_interval.hint": "Client-side rate limit between commands in ms. UDP fire-and-forget tolerates fast updates; default 50 ms ≈ 20 Hz.", - "device.type.nanoleaf": "Nanoleaf", - "device.type.nanoleaf.desc": "Nanoleaf Light Panels / Canvas / Shapes / Lines / Elements (HTTP REST, requires pairing)", - "device.nanoleaf.url": "IP Address:", - "device.nanoleaf.url.hint": "LAN IP of the Nanoleaf controller. HTTP port 16021 is fixed in the protocol.", - "device.nanoleaf.url.placeholder": "192.168.1.50", - "device.nanoleaf_min_interval": "Min Update Interval:", - "device.nanoleaf_min_interval.hint": "Client-side rate limit between commands in ms. Default 100 ms ≈ 10 Hz; HTTP request overhead caps the practical max around 20 Hz.", - "device.nanoleaf.pair.instructions": "Press and hold the power button on your Nanoleaf controller for 5 seconds until the LEDs flash, then click Start. The controller opens a 30-second pairing window.", - "device.nanoleaf.pair_button": "Pair Device", - "device.nanoleaf.token.label": "Auth token:", - "device.nanoleaf.paired": "Paired", - "device.type.ble": "BLE LED Controller", - "device.type.ble.desc": "Bluetooth LE strips: SP110E, Triones, Zengge, Govee (whole-strip color)", - "device.ble.url": "BLE Address:", - "device.ble.url.hint": "MAC address (Windows/Linux) or UUID (macOS), prefixed with ble://", - "device.ble.family": "Protocol Family:", - "device.ble.family.hint": "Which BLE protocol your controller speaks. Match the phone app you normally use.", - "device.ble.family.sp110e.desc": "Addressable controllers — LED Hue / SP110E app", - "device.ble.family.triones.desc": "Single-color controllers — HappyLighting / LEDnet app", - "device.ble.family.zengge.desc": "Single-color controllers — iLightsIn / Mohuan app", - "device.ble.family.govee.desc": "Govee H6xxx strips — unencrypted firmware only", - "device.ble.govee_key": "Govee AES Key (hex):", - "device.ble.govee_key.hint": "Optional. Newer Govee firmware needs a per-model AES key — leave blank for older firmware.", - "device.ble.govee_key.placeholder": "32 hex digits, e.g. 0102…1f20", - "device.type.usbhid": "USB HID", - "device.type.usbhid.desc": "USB RGB peripherals (keyboards, mice)", - "device.type.spi": "SPI Direct", - "device.type.spi.desc": "Raspberry Pi GPIO/SPI LED strips", - "device.type.chroma": "Razer Chroma", - "device.type.chroma.desc": "Razer peripherals via Chroma SDK", - "device.type.gamesense": "SteelSeries", - "device.type.gamesense.desc": "SteelSeries peripherals via GameSense", - "device.type.group": "Group", - "device.type.group.desc": "Combine multiple devices into one virtual device", - "device.group.children": "Child Devices:", - "device.group.children.hint": "Select devices to include in this group. Order matters for sequence mode.", - "device.group.add_child": "+ Add Device", - "device.group.select_device": "Select a device", - "device.group.mode": "Group Mode:", - "device.group.mode.hint": "Sequence concatenates LEDs end-to-end. Independent mirrors the full strip to each device.", - "device.group.mode.sequence": "Sequence", - "device.group.mode.sequence.desc": "Concatenate LEDs end-to-end into one long strip", - "device.group.mode.independent": "Independent", - "device.group.mode.independent.desc": "Mirror the full strip to each device independently", - "device.group.move_up": "Move up", - "device.group.move_down": "Move down", - "device.group.error.no_children": "Add at least one child device to the group.", - "device.chroma.device_type": "Peripheral Type:", - "device.chroma.device_type.hint": "Which Razer peripheral to control via Chroma SDK", - "device.gamesense.device_type": "Peripheral Type:", - "device.gamesense.device_type.hint": "Which SteelSeries peripheral to control via GameSense", - "device.espnow.peer_mac": "Peer MAC:", - "device.espnow.peer_mac.hint": "MAC address of the remote ESP32 receiver (e.g. AA:BB:CC:DD:EE:FF)", - "device.espnow.channel": "WiFi Channel:", - "device.espnow.channel.hint": "WiFi channel (1-14). Must match the receiver's channel.", - "device.hue.url": "Bridge IP:", - "device.hue.url.hint": "IP address of your Hue bridge", - "device.hue.username": "Bridge Username:", - "device.hue.username.hint": "Hue bridge application key from pairing", - "device.hue.client_key": "Client Key:", - "device.hue.client_key.hint": "Entertainment API client key (hex string from pairing)", - "device.hue.group_id": "Entertainment Group:", - "device.hue.group_id.hint": "Entertainment configuration ID from your Hue bridge", - "device.usbhid.url": "VID:PID:", - "device.usbhid.url.hint": "USB Vendor:Product ID in hex (e.g. 1532:0084)", - "device.spi.url": "GPIO/SPI Path:", - "device.spi.url.hint": "GPIO pin or SPI device path (e.g. spi://gpio:18)", - "device.spi.speed": "SPI Speed (Hz):", - "device.spi.speed.hint": "SPI clock speed. 800000 Hz for WS2812, 2400000 Hz for APA102.", - "device.spi.led_type": "LED Chipset:", - "device.spi.led_type.hint": "Type of addressable LED strip connected to the GPIO/SPI pin", - "device.spi.led_type.ws2812b.desc": "Most common, 800 KHz data, 3-wire RGB", - "device.spi.led_type.ws2812.desc": "Original WS2812, 800 KHz, 3-wire RGB", - "device.spi.led_type.ws2811.desc": "External driver IC, 400 KHz, 12V strips", - "device.spi.led_type.sk6812.desc": "Samsung LED, 800 KHz, 3-wire RGB", - "device.spi.led_type.sk6812_rgbw.desc": "SK6812 with dedicated white channel", - "device.gamesense.peripheral.keyboard": "Keyboard", - "device.gamesense.peripheral.keyboard.desc": "Per-key RGB illumination", - "device.gamesense.peripheral.mouse": "Mouse", - "device.gamesense.peripheral.mouse.desc": "Mouse RGB zones", - "device.gamesense.peripheral.headset": "Headset", - "device.gamesense.peripheral.headset.desc": "Headset earcup lighting", - "device.gamesense.peripheral.mousepad": "Mousepad", - "device.gamesense.peripheral.mousepad.desc": "Mousepad edge lighting zones", - "device.gamesense.peripheral.indicator": "Indicator", - "device.gamesense.peripheral.indicator.desc": "OLED/LED status indicator", - "device.css_processing_template": "Strip Processing Template:", - "device.css_processing_template.hint": "Default processing template applied to all color strip outputs on this device", - "device.dmx_protocol": "DMX Protocol:", - "device.dmx_protocol.hint": "Art-Net uses UDP port 6454, sACN (E1.31) uses UDP port 5568", - "device.dmx_protocol.artnet.desc": "UDP unicast, port 6454", - "device.dmx_protocol.sacn.desc": "Multicast/unicast, port 5568", - "device.dmx_start_universe": "Start Universe:", - "device.dmx_start_universe.hint": "First DMX universe (0-32767). Multiple universes are used automatically for >170 LEDs.", - "device.dmx_start_channel": "Start Channel:", - "device.dmx_start_channel.hint": "First DMX channel within the universe (1-512)", - "device.dmx.url": "IP Address:", - "device.dmx.url.hint": "IP address of the DMX node (e.g. 192.168.1.50)", - "device.dmx.url.placeholder": "192.168.1.50", - "device.ddp.url": "IP Address:", - "device.ddp.url.hint": "DDP receiver address. Port defaults to 4048.", - "device.ddp.url.placeholder": "192.168.1.50", - "device.ddp_port": "DDP Port:", - "device.ddp_port.hint": "UDP port (0 = protocol default 4048).", - "device.ddp_destination_id": "Destination ID:", - "device.ddp_destination_id.hint": "DDP destination identifier (1 = display).", - "device.ddp_color_order": "Color Order:", - "device.ddp_color_order.hint": "Channel byte order on the wire. Most DDP receivers expect RGB.", - "device.ddp.color_order.rgb.desc": "Standard RGB byte order", - "device.ddp.color_order.grb.desc": "WS2812/WS2812B native order", - "device.ddp.color_order.brg.desc": "BRG byte order", - "device.ddp.color_order.rbg.desc": "RBG byte order", - "device.ddp.color_order.bgr.desc": "BGR byte order", - "device.ddp.color_order.gbr.desc": "GBR byte order", - "device.opc.url": "IP Address:", - "device.opc.url.hint": "OPC receiver address. TCP port defaults to 7890.", - "device.opc.url.placeholder": "192.168.1.50", - "device.opc_channel": "Channel:", - "device.opc_channel.hint": "OPC channel (0 = broadcast to every channel on the server, 1-255 = specific output).", - "device.serial_port": "Serial Port:", - "device.serial_port.hint": "Select the COM port of the Adalight device", - "device.serial_port.none": "No serial ports found", - "device.serial_port.select": "Select a port...", - "device.led_count_manual.hint": "Number of LEDs on the strip (must match your Arduino sketch)", - "device.baud_rate": "Baud Rate:", - "device.baud_rate.hint": "Serial communication speed. Higher = more FPS but requires matching Arduino sketch.", - "device.led_type": "LED Type:", - "device.led_type.hint": "RGB (3 channels) or RGBW (4 channels with dedicated white)", - "device.send_latency": "Send Latency (ms):", - "device.send_latency.hint": "Simulated network/serial delay per frame in milliseconds", - "device.mqtt_topic": "MQTT Topic:", - "device.mqtt_topic.hint": "MQTT topic path for publishing pixel data (e.g. mqtt://ledgrab/device/name)", - "device.mqtt_topic.placeholder": "mqtt://ledgrab/device/living-room", - "device.mqtt_source": "MQTT Broker:", - "device.mqtt_source.hint": "Which MQTT broker this device publishes to. Manage brokers under Integrations → MQTT Sources. Leave unset to use the first available broker.", - "device.mqtt_source.none": "— First available broker", - "device.ws_url": "Connection URL:", - "device.ws_url.hint": "WebSocket URL for clients to connect and receive LED data", - "device.openrgb.url": "OpenRGB URL:", - "device.openrgb.url.hint": "OpenRGB server address (e.g. openrgb://localhost:6742/0)", - "device.openrgb.zone": "Zones:", - "device.openrgb.zone.hint": "Select which LED zones to control (leave all unchecked for all zones)", - "device.openrgb.zone.loading": "Loading zones…", - "device.openrgb.zone.error": "Failed to load zones", - "device.openrgb.mode": "Zone mode:", - "device.openrgb.mode.hint": "Combined treats all zones as one continuous LED strip. Separate renders each zone independently with the full effect.", - "device.openrgb.mode.combined": "Combined strip", - "device.openrgb.mode.separate": "Independent zones", - "device.openrgb.added_multiple": "Added {count} devices", - "device.url.hint": "IP address or hostname of the device (e.g. http://192.168.1.100)", - "device.name": "Device Name:", - "device.name.placeholder": "Living Room TV", - "device.url": "URL:", - "device.url.placeholder": "http://192.168.1.100", - "device.led_count": "LED Count:", - "dashboard.device.chip": "Chip:", - "device.led_count.hint": "Number of LEDs configured in the device", - "device.led_count.hint.auto": "Auto-detected from device", - "device.button.add": "Add Device", - "device.button.start": "Start", - "device.button.stop": "Stop", - "device.button.settings": "General Settings", - "device.button.capture_settings": "Capture Settings", - "device.button.calibrate": "Calibrate", - "device.button.remove": "Remove", - "device.button.webui": "Open Device Web UI", - "device.button.power_off": "Turn Off", - "device.button.ping": "Ping Device", - "device.ping.online": "Online ({ms}ms)", - "device.ping.offline": "Device offline", - "device.ping.error": "Ping failed", - "device.power.off_success": "Device turned off", - "device.status.connected": "Connected", - "device.status.disconnected": "Disconnected", - "device.status.error": "Error", - "device.status.processing": "Processing", - "device.status.idle": "Idle", - "device.fps": "FPS:", - "device.display": "Display:", - "device.remove.confirm": "Are you sure you want to remove this device?", - "device.added": "Device added successfully", - "device.removed": "Device removed", - "device.started": "Processing started", - "device.stopped": "Processing stopped", - "device.metrics.actual_fps": "Actual FPS", - "device.metrics.current_fps": "Current FPS", - "device.metrics.target_fps": "Target FPS", - "device.metrics.potential_fps": "Potential FPS", - "device.metrics.frames": "Frames", - "device.metrics.frames_skipped": "Skipped", - "device.metrics.keepalive": "Keepalive", - "device.metrics.errors": "Errors", - "device.metrics.uptime": "Uptime", - "device.metrics.timing": "Pipeline timing:", - "device.metrics.device_fps": "Device refresh rate", - "device.health.online": "Online", - "device.health.offline": "Offline", - "device.health.streaming_unreachable": "Unreachable during streaming", - "device.health.checking": "Checking...", - "patch.streaming": "STREAMING", - "patch.standby": "STANDBY", - "patch.online": "ONLINE", - "patch.offline": "OFFLINE", - "patch.disconnected": "DISCONNECTED", - "patch.not_configured": "NOT CONFIGURED", - "patch.unreachable": "UNREACHABLE", - "patch.checking": "CHECKING", - "patch.ticking": "TICKING", - "patch.paused": "PAUSED", - "patch.patched": "PATCHED", - "patch.preset": "PRESET", - "patch.source": "SOURCE", - "patch.template": "TEMPLATE", - "patch.pipeline": "PIPELINE", - "patch.strip": "STRIP", - "patch.value": "VALUE", - "patch.ready": "READY", - "patch.polling": "POLLING", - "perf.idle": "idle", - "perf.offline": "offline", - "perf.online": "online", - "perf.no_devices": "no devices", - "perf.all_online": "all online", - "perf.online_count": "{count} online", - "perf.offline_count": "{count} offline", - "perf.total_bytes": "{bytes} total", - "perf.max_ms": "max {ms}ms", - "perf.targets_count.one": "{count} target", - "perf.targets_count.other": "{count} targets", - "perf.no_captures": "no captures", - "perf.captures_count.one": "{count} capture", - "perf.captures_count.other": "{count} captures", - "perf.ratio_of_requested": "{percent}% of requested · {captures}", - "perf.total_count": "{count} total", - "perf.skipped_per_sec": "{rate} skipped/s", - "perf.tip.now": "now", - "perf.tip.ago": "−{seconds}s", - "device.last_seen.label": "Last seen", - "device.last_seen.just_now": "just now", - "device.last_seen.seconds": "%ds ago", - "device.last_seen.minutes": "%dm ago", - "device.last_seen.hours": "%dh ago", - "device.last_seen.days": "%dd ago", - "device.tutorial.start": "Start tutorial", - "device.tip.identity": "Device identity — name, type badge, and health indicator showing online/offline status.", - "device.tip.metadata": "Device address and firmware version. Click the URL to open the device's built-in web UI.", - "device.tip.brightness": "Drag to adjust device brightness. Changes are sent to the device immediately.", - "device.brightness": "Bright", - "device.tip.ping": "Ping the device to refresh online status and latency.", - "device.tip.settings": "Open device settings — configure name, URL, capabilities, and health checks.", - "device.tip.menu": "More actions — duplicate, hide, or delete this device.", - "device.tip.add": "Click here to add a new LED device", - "settings.title": "Settings", - "settings.tab.general": "General", - "settings.tab.backup": "Backup", - "settings.tab.mqtt": "MQTT", - "settings.tab.appearance": "Appearance", - "settings.logs.open_viewer": "Open Log Viewer", - "settings.external_url.label": "External URL", - "settings.external_url.hint": "If set, this base URL is used in webhook URLs and other user-visible links instead of the auto-detected local IP. Example: https://myserver.example.com:8080", - "settings.external_url.placeholder": "https://myserver.example.com:8080", - "settings.external_url.save": "Save", - "settings.external_url.saved": "External URL saved", - "settings.external_url.save_error": "Failed to save external URL", - "settings.general.title": "General Settings", - "settings.section.identity": "Identity", - "settings.section.connection": "Connection", - "settings.section.hardware": "Hardware", - "settings.section.behavior": "Behavior", - "settings.section.provider": "Provider", - "settings.section.refresh": "Refresh", - "settings.section.filters": "Filters", - "settings.section.routing": "Routing", - "settings.section.output": "Output", - "settings.section.filtering": "Filtering", - "settings.section.broker": "Broker", - "settings.section.protocol": "Protocol", - "settings.section.adapter": "Adapter", - "settings.section.mappings": "Mappings", - "settings.section.diagnostics": "Diagnostics", - "settings.section.source": "Source", - "settings.section.engine": "Engine", - "settings.section.timing": "Timing", - "settings.section.gradient": "Gradient", - "settings.section.layout": "Layout", - "settings.section.strip": "Strip", - "settings.section.type": "Type", - "settings.section.notes": "Notes", - "settings.section.file": "File", - "settings.section.auth": "Auth", - "settings.section.configure": "Configure", - "settings.section.restart": "Restart", - "settings.section.loopback": "Loopback", - "settings.section.test_setup": "Test Setup", - "settings.section.offsets": "Offsets", - "settings.section.line_properties": "Line Properties", - "settings.section.test": "Test", - "settings.section.preview": "Preview", - "settings.section.controls": "Controls", - "settings.section.history": "History", - "ha_source.use_ssl.hint": "Enable for HTTPS / wss connections to Home Assistant", - "game_integration.enabled.hint": "Disabled integrations stop polling and emitting events", - "settings.capture.title": "Capture Settings", - "settings.capture.saved": "Capture settings updated", - "settings.capture.failed": "Failed to save capture settings", - "settings.brightness": "Brightness:", - "settings.brightness.hint": "Global brightness for this device (0-100%)", - "settings.url.hint": "IP address or hostname of the device", - "settings.display_index": "Display:", - "settings.display_index.hint": "Which screen to capture for this device", - "settings.fps": "Target FPS:", - "settings.fps.hint": "Target frames per second (10-90)", - "settings.capture_template": "Engine Template:", - "settings.capture_template.hint": "Screen capture engine and configuration for this device", - "settings.button.cancel": "Cancel", - "settings.health_interval": "Health Check Interval (s):", - "settings.health_interval.hint": "How often to check the device status (5-600 seconds)", - "settings.auto_shutdown": "Auto Restore:", - "settings.auto_shutdown.hint": "Restore device to idle state when targets stop or server shuts down", - "settings.button.save": "Save Changes", - "settings.saved": "Settings saved successfully", - "settings.failed": "Failed to save settings", - "calibration.title": "LED Calibration", - "calibration.tip.led_count": "Enter LED count per edge", - "calibration.tip.start_corner": "Click a corner to set the start position", - "calibration.tip.direction": "Toggle LED strip direction (clockwise / counterclockwise)", - "calibration.tip.offset": "Set LED offset — distance from LED 0 to the start corner", - "calibration.tip.span": "Drag green bars to adjust coverage span", - "calibration.tip.test": "Click an edge to toggle test LEDs", - "calibration.tip.overlay": "Toggle screen overlay to see LED positions and numbering on your monitor", - "calibration.tip.toggle_inputs": "Click total LED count to toggle edge inputs", - "calibration.tip.border_width": "How many pixels from the screen edge to sample for LED colors", - "calibration.tip.skip_leds_start": "Skip LEDs at the start of the strip — skipped LEDs stay off", - "calibration.tip.skip_leds_end": "Skip LEDs at the end of the strip — skipped LEDs stay off", - "tour.welcome": "Welcome to LED Grab! This quick tour will show you around the interface. Use arrow keys or buttons to navigate.", - "tour.dashboard": "Dashboard — live overview of running targets, automations, and device health at a glance.", - "tour.targets": "Targets — add WLED devices, configure LED targets with capture settings and calibration.", - "tour.sources": "Sources — manage capture templates, picture sources, audio sources, and color strips.", - "tour.integrations": "Integrations — connect external services: Weather, Home Assistant, MQTT, and Game integrations.", - "tour.graph": "Graph — visual overview of all entities and their connections. Drag ports to connect, right-click edges to disconnect.", - "tour.automations": "Automations — automate scene switching with time, audio, or value rules.", - "tour.settings": "Settings — backup and restore configuration, manage auto-backups.", - "tour.api": "API Docs — interactive REST API documentation powered by Swagger.", - "tour.search": "Search — quickly find and navigate to any entity with Ctrl+K.", - "tour.theme": "Theme — switch between dark and light mode.", - "tour.accent": "Accent color — customize the UI accent color to your preference.", - "tour.language": "Language — choose your preferred interface language.", - "tour.restart": "Restart tutorial", - "tour.dash.perf": "Performance — real-time FPS charts, latency metrics, and poll interval control.", - "tour.dash.targets": "Channels — all your targets in one place, grouped into running and stopped subsections.", - "tour.dash.running": "Running targets — live streaming metrics and quick stop control.", - "tour.dash.stopped": "Stopped targets — ready to start with one click.", - "tour.dash.automations": "Automations — active automation status and quick enable/disable toggle.", - "tour.dash.scenes": "Scene Presets — saved system snapshots you can apply with a single click.", - "tour.dash.sync_clocks": "Sync Clocks — shared timers that synchronize animations across sources.", - "tour.dash.integrations": "Integrations — connection status for Weather, Home Assistant, and MQTT sources.", - "tour.dash.customize_btn": "Customize button — opens a side panel where you can reorder sections, toggle visibility, and tune the dashboard layout.", - "tour.dash.customize_panel": "Dashboard Customize — your control center for layout. Changes apply live; close with Esc or the × button.", - "tour.dash.customize_presets": "Presets — apply curated layouts (compact, focused, full) in one click. Switching to a preset overrides any manual tweaks.", - "tour.dash.customize_global": "Global settings — content width, animation level, performance mode (system/app/both), and the sample window for charts.", - "tour.dash.customize_sections": "Sections — drag to reorder; click the eye to hide; click the chevron to start collapsed by default. Each section can also be tagged dense/compact/comfortable.", - "tour.dash.customize_perf_cells": "Performance cells — choose which metric tiles to show in the Performance section, configure their span, sample window, and Y-axis scale.", - "tour.tgt.nav": "Use this dropdown to switch between target types: LED devices, LED targets, and Home Assistant lights.", - "tour.tgt.devices": "Devices — your LED controllers discovered on the network.", - "tour.tgt.css": "Color Strips — define how screen regions map to LED segments.", - "tour.tgt.targets": "LED Targets — combine a device, color strip, and capture source for streaming.", - "tour.tgt.ha_light_targets": "HA Light Targets — pick a Home Assistant light entity and a color/value source to drive it.", - "tour.src.nav": "Use this dropdown to switch between source types — pictures, color strips, audio, value, sync, and assets.", - "tour.src.raw": "Raw — live screen capture sources from your displays.", - "tour.src.templates": "Capture Templates — reusable capture configurations (resolution, FPS, crop).", - "tour.src.static": "Static Image — test your setup with image files instead of live capture.", - "tour.src.processed": "Processed — apply post-processing effects like blur, brightness, or color correction.", - "tour.src.color_strip": "Color Strips — define how screen regions map to LED segments.", - "tour.src.audio": "Audio — capture microphone/system audio and process it through templates for reactive effects.", - "tour.src.value": "Value Sources — dynamic numbers or colors driven by time of day, audio, system metrics, Home Assistant entities, gradients, or schedules. Used to animate effects, modulate color strips, and trigger automations.", - "tour.src.sync": "Sync Clocks — shared timers that synchronize animations across multiple sources.", - "tour.auto.list": "Automations — automate scene activation based on time, audio, or value rules.", - "tour.auto.add": "Click + to create a new automation with rules and a scene to activate.", - "tour.auto.card": "Each card shows automation status, rules, and quick controls to edit or toggle.", - "tour.auto.scenes_list": "Scenes — saved system states that automations can activate or you can apply manually.", - "tour.auto.scenes_add": "Click + to capture the current system state as a new scene preset.", - "tour.auto.scenes_card": "Each scene card shows target/device counts. Click to edit, recapture, or activate.", - "tour.int.nav": "Use this dropdown to switch between integration types: Weather, Home Assistant, MQTT, and Game integrations.", - "tour.int.weather": "Weather — pull live weather data (temperature, conditions, forecast) into value sources for environmental effects.", - "tour.int.home_assistant": "Home Assistant — connect to your HA instance. Lights, sensors, and entities become value sources and target endpoints.", - "tour.int.mqtt": "MQTT — subscribe to broker topics to feed external sensor data, automations, or remote triggers into LedGrab.", - "tour.int.game": "Game — react to in-game events (Minecraft, Hammer of God, etc.) by triggering scenes and color effects from game state.", - "calibration.tutorial.start": "Start tutorial", - "calibration.overlay_toggle": "Overlay", - "calibration.start_position": "Starting Position:", - "calibration.position.bottom_left": "Bottom Left", - "calibration.position.bottom_right": "Bottom Right", - "calibration.position.top_left": "Top Left", - "calibration.position.top_right": "Top Right", - "calibration.direction": "Direction:", - "calibration.direction.clockwise": "Clockwise", - "calibration.direction.counterclockwise": "Counterclockwise", - "calibration.leds.top": "Top LEDs:", - "calibration.leds.right": "Right LEDs:", - "calibration.leds.bottom": "Bottom LEDs:", - "calibration.leds.left": "Left LEDs:", - "calibration.offset": "LED Offset:", - "calibration.offset.hint": "Distance from physical LED 0 to the start corner (along strip direction)", - "calibration.skip_start": "Skip LEDs (Start):", - "calibration.skip_start.hint": "Number of LEDs to turn off at the beginning of the strip (0 = none)", - "calibration.skip_end": "Skip LEDs (End):", - "calibration.skip_end.hint": "Number of LEDs to turn off at the end of the strip (0 = none)", - "calibration.border_width": "Border (px):", - "calibration.roi": "Capture region (%):", - "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.", - "calibration.roi.x": "X (%)", - "calibration.roi.y": "Y (%)", - "calibration.roi.width": "Width (%)", - "calibration.roi.height": "Height (%)", - "calibration.border_width.hint": "How many pixels from the screen edge to sample for LED colors (1-100)", - "calibration.button.cancel": "Cancel", - "calibration.button.save": "Save", - "calibration.saved": "Calibration saved", - "calibration.failed": "Failed to save calibration", - "server.healthy": "Server online", - "server.offline": "Server offline", - "error.unauthorized": "Unauthorized - please login", - "error.network": "Network error", - "error.unknown": "An error occurred", - "modal.discard_changes": "You have unsaved changes. Discard them?", - "confirm.title": "Confirm Action", - "confirm.yes": "Yes", - "confirm.no": "No", - "confirm.stop_all": "Stop all running targets?", - "confirm.turn_off_device": "Turn off this device?", - "common.loading": "Loading...", - "common.delete": "Delete", - "common.remove": "Remove", - "common.edit": "Edit", - "common.clone": "Clone", - "common.hide": "Hide card", - "common.more_actions": "More actions", - "common.card_color": "Card color", - "common.none": "None", - "common.none_no_cspt": "None (no processing template)", - "common.none_no_input": "None (no input source)", - "common.none_own_speed": "None (no sync)", - "common.undo": "Undo", - "common.cancel": "Cancel", - "common.back": "Back", - "common.apply": "Apply", - "common.start": "START", - "common.stop": "STOP", - "common.led_preview": "LED Preview", - "device.icon.eyebrow": "Card icon", - "device.icon.title": "Choose an icon", - "device.icon.for": "for", - "device.icon.choose": "Choose icon…", - "device.icon.change": "Change icon…", - "device.icon.remove": "Remove icon", - "device.icon.search.placeholder": "Search icons…", - "device.icon.color_toggle": "Color", - "device.icon.recent": "Recent", - "device.icon.empty": "No icons match.", - "device.icon.hint": "↵ Apply · Esc Cancel", - "device.icon.saved": "Icon saved", - "device.icon.error.save_failed": "Failed to save icon", - "device.icon.cat.all": "All", - "device.icon.cat.hardware": "Hardware", - "device.icon.cat.lighting": "Lighting", - "device.icon.cat.rooms": "Rooms", - "device.icon.cat.media": "Media", - "device.icon.cat.signal": "Signal", - "device.icon.cat.ambience": "Ambience", - "device.icon.cat.weather": "Weather", - "device.icon.cat.nature": "Nature", - "device.icon.cat.controls": "Controls", - "device.icon.cat.status": "Status", - "device.icon.cat.office": "Office", - "device.icon.phone": "Phone", - "device.icon.package": "Device unit", - "device.icon.code": "Code", - "device.icon.laptop": "Laptop", - "device.icon.server": "Server", - "device.icon.router": "Router", - "device.icon.webcam": "Webcam", - "device.icon.bot": "Bot", - "device.icon.watch": "Smartwatch", - "device.icon.mcu": "Microcontroller", - "device.icon.dim": "Dim light", - "device.icon.ring": "Ring light", - "device.icon.point": "Point light", - "device.icon.ceiling": "Ceiling light", - "device.icon.desk_lamp": "Desk lamp", - "device.icon.wall_light": "Wall light", - "device.icon.flashlight": "Flashlight", - "device.icon.bulb_off": "Bulb off", - "device.icon.candle": "Candle", - "device.icon.kitchen": "Kitchen", - "device.icon.bath": "Bathroom", - "device.icon.garage": "Garage", - "device.icon.outdoor": "Outdoor", - "device.icon.projector": "Projector", - "device.icon.camcorder": "Camcorder", - "device.icon.disc": "Disc", - "device.icon.image": "Image", - "device.icon.audio_file": "Audio file", - "device.icon.send": "Send", - "device.icon.rain": "Rain", - "device.icon.snow": "Snow", - "device.icon.thunder": "Thunder", - "device.icon.fog": "Fog", - "device.icon.wind": "Wind", - "device.icon.snowflake": "Snowflake", - "device.icon.umbrella": "Umbrella", - "device.icon.sunrise": "Sunrise", - "device.icon.sunset": "Sunset", - "device.icon.tree": "Tree", - "device.icon.flower": "Flower", - "device.icon.mountain": "Mountain", - "device.icon.waves": "Waves", - "device.icon.sprout": "Sprout", - "device.icon.water_drops": "Water drops", - "device.icon.leaf": "Leaf", - "device.icon.switch": "Switch", - "device.icon.slider": "Slider", - "device.icon.settings": "Settings", - "device.icon.refresh": "Refresh", - "device.icon.undo": "Undo", - "device.icon.trash": "Trash", - "device.icon.link": "Link", - "device.icon.search": "Search", - "device.icon.add": "Add", - "device.icon.show": "Show", - "device.icon.lock": "Lock", - "device.icon.key": "Key", - "device.icon.tool": "Tool", - "device.icon.check": "Check", - "device.icon.ok": "OK", - "device.icon.warning": "Warning", - "device.icon.help": "Help", - "device.icon.off": "Disabled", - "device.icon.shield": "Shield", - "device.icon.target": "Target", - "device.icon.pulse": "Pulse", - "device.icon.trend": "Trending", - "device.icon.battery": "Battery", - "device.icon.calendar": "Calendar", - "device.icon.mail": "Mail", - "device.icon.coffee": "Coffee", - "device.icon.briefcase": "Briefcase", - "device.icon.doc": "Document", - "device.icon.checklist": "Checklist", - "device.icon.hashtag": "Hashtag", - "device.icon.clock": "Clock", - "device.icon.entity.device": "Device", - "device.icon.entity.target": "LED target", - "device.icon.entity.ha_light_target": "HA light target", - "device.icon.entity.picture_source": "Picture source", - "device.icon.entity.audio_source": "Audio source", - "device.icon.entity.weather_source": "Weather source", - "device.icon.entity.value_source": "Value source", - "device.icon.entity.mqtt_source": "MQTT source", - "device.icon.entity.ha_source": "Home Assistant source", - "device.icon.entity.automation": "Automation", - "device.icon.entity.scene_preset": "Scene preset", - "device.icon.entity.scene_playlist": "Playlist", - "device.icon.entity.sync_clock": "Sync clock", - "device.icon.entity.game_integration": "Game integration", - "device.icon.entity.audio_processing_template": "Audio processing template", - "device.icon.entity.pattern_template": "Pattern template", - "device.icon.entity.capture_template": "Capture template", - "device.icon.entity.pp_template": "Post-processing template", - "device.icon.entity.cspt": "Color-strip processing template", - "device.icon.entity.audio_template": "Audio template", - "device.icon.entity.gradient": "Gradient", - "device.icon.entity.color_strip_source": "Color strip", - "device.icon.entity.asset": "Asset", - "device.icon.inherited_from": "Inherited from %s", - "device.icon.override_inherited": "Override inherited icon…", - "device.icon.use_inherited": "Use inherited", - "validation.required": "This field is required", - "bulk.processing": "Processing…", - "api.error.timeout": "Request timed out — please try again", - "api.error.network": "Network error — check your connection", - "palette.search": "Search…", - "section.filter.placeholder": "Filter...", - "section.filter.reset": "Clear filter", - "section.hide": "Hide", - "section.show_hidden": "Show hidden cards", - "tags.label": "Tags", - "tags.hint": "Assign tags for grouping and filtering cards", - "tags.placeholder": "Add tag...", - "section.expand_all": "Expand all sections", - "section.collapse_all": "Collapse all sections", - "streams.title": "Inputs", - "integrations.title": "Integrations", - "streams.description": "Sources define the capture pipeline. A raw source captures from a display using a capture template. A processed source applies postprocessing to another source. Assign sources to devices.", - "streams.group.raw": "Sources", - "streams.group.raw_templates": "Engine Templates", - "streams.group.processed": "Sources", - "streams.group.proc_templates": "Filter Templates", - "streams.group.css_processing": "Processing Templates", - "streams.group.color_strip": "Color Strips", - "streams.group.audio": "Audio", - "streams.group.audio_templates": "Audio Templates", - "streams.section.streams": "Sources", - "streams.add": "Add Source", - "streams.add.raw": "Add Screen Capture", - "streams.add.processed": "Add Processed Source", - "streams.edit": "Edit Source", - "streams.edit.raw": "Edit Screen Capture", - "streams.edit.processed": "Edit Processed Source", - "streams.name": "Source Name:", - "streams.name.placeholder": "My Source", - "streams.type": "Type:", - "streams.type.raw": "Screen Capture", - "streams.type.processed": "Processed", - "streams.display": "Display:", - "streams.display.hint": "Which screen to capture", - "streams.capture_template": "Engine Template:", - "streams.capture_template.hint": "Engine template defining how the screen is captured", - "streams.target_fps": "Target FPS:", - "streams.target_fps.hint": "Target frames per second for capture (1-90)", - "streams.source": "Source:", - "streams.source.hint": "The source to apply processing filters to", - "streams.pp_template": "Filter Template:", - "streams.pp_template.hint": "Filter template to apply to the source", - "streams.description_label": "Description (optional):", - "streams.description_placeholder": "Describe this source...", - "streams.created": "Source created successfully", - "streams.updated": "Source updated successfully", - "streams.deleted": "Source deleted successfully", - "streams.delete.confirm": "Are you sure you want to delete this source?", - "streams.modal.loading": "Loading...", - "streams.error.load": "Failed to load sources", - "streams.error.required": "Please fill in all required fields", - "streams.error.delete": "Failed to delete source", - "streams.test.title": "Test Source", - "streams.test.run": "Run", - "streams.test.running": "Testing source...", - "streams.test.duration": "Capture Duration (s):", - "streams.test.error.failed": "Source test failed", - "postprocessing.title": "Filter Templates", - "postprocessing.description": "Processing templates define image filters and color correction. Assign them to processed picture sources for consistent postprocessing across devices.", - "postprocessing.add": "Add Filter Template", - "postprocessing.edit": "Edit Filter Template", - "postprocessing.name": "Template Name:", - "postprocessing.name.placeholder": "My Filter Template", - "filters.select_type": "Select filter type...", - "filters.add": "Add Filter", - "filters.remove": "Remove", - "filters.drag_to_reorder": "Drag to reorder", - "filters.empty": "No filters added. Use the selector below to add filters.", - "filters.brightness": "Brightness", - "filters.brightness.desc": "Adjust overall image brightness", - "filters.saturation": "Saturation", - "filters.saturation.desc": "Boost or reduce color intensity", - "filters.gamma": "Gamma", - "filters.gamma.desc": "Non-linear brightness curve correction", - "filters.downscaler": "Downscaler", - "filters.downscaler.desc": "Reduce resolution for faster processing", - "filters.pixelate": "Pixelate", - "filters.pixelate.desc": "Mosaic-style block averaging", - "filters.auto_crop": "Auto Crop", - "filters.auto_crop.desc": "Remove black bars from letterboxed content", - "filters.flip": "Flip", - "filters.flip.desc": "Mirror image horizontally or vertically", - "filters.color_correction": "Color Correction", - "filters.color_correction.desc": "White balance and color temperature", - "filters.filter_template": "Filter Template", - "filters.filter_template.desc": "Embed another processing template", - "filters.css_filter_template": "Strip Filter Template", - "filters.css_filter_template.desc": "Embed another strip processing template", - "filters.frame_interpolation": "Frame Interpolation", - "filters.frame_interpolation.desc": "Blend between frames for smoother output", - "filters.noise_gate": "Noise Gate", - "filters.noise_gate.desc": "Suppress small color changes below threshold", - "filters.palette_quantization": "Palette Quantization", - "filters.palette_quantization.desc": "Reduce colors to a limited palette", - "filters.reverse": "Reverse", - "filters.reverse.desc": "Reverse the LED order in the strip", - "filters.hsl_shift": "HSL Shift", - "filters.hsl_shift.desc": "Shift hue, saturation, and lightness values", - "filters.contrast": "Contrast", - "filters.contrast.desc": "Adjust image contrast around mid-gray", - "filters.temporal_blur": "Temporal Blur", - "filters.temporal_blur.desc": "Smooth color transitions over time", - "filters.audio_filter_template.desc": "Embed another audio processing template", - "filters.channel_extract.desc": "Select mono, left, or right channel", - "filters.band_extract.desc": "Filter to bass, mid, treble, or custom frequency range", - "filters.peak_hold.desc": "Retain peak values with configurable decay", - "filters.gain.desc": "Amplify or attenuate signal levels", - "filters.envelope_follower.desc": "Smooth amplitude with attack/release times", - "filters.spectral_smoothing.desc": "Extra smoothing on spectrum data over time", - "filters.compressor.desc": "Reduce dynamic range above threshold", - "filters.inverter.desc": "Invert all levels (1 minus value)", - "filters.beat_gate.desc": "Pass signal only around detected beats", - "filters.delay.desc": "Time-shift the audio analysis by a delay", - "filters.auto_gain.desc": "Auto-normalize audio levels to use full range", - "postprocessing.description_label": "Description (optional):", - "postprocessing.description_placeholder": "Describe this template...", - "postprocessing.created": "Template created successfully", - "postprocessing.updated": "Template updated successfully", - "postprocessing.deleted": "Template deleted successfully", - "postprocessing.delete.confirm": "Are you sure you want to delete this filter template?", - "postprocessing.error.load": "Failed to load processing templates", - "postprocessing.error.required": "Please fill in all required fields", - "postprocessing.error.delete": "Failed to delete processing template", - "postprocessing.config.show": "Show settings", - "postprocessing.test.title": "Test Filter Template", - "postprocessing.test.source_stream": "Source:", - "postprocessing.test.running": "Testing processing template...", - "postprocessing.test.error.no_stream": "Please select a source", - "postprocessing.test.error.failed": "Processing template test failed", - "css_processing.title": "Strip Processing Templates", - "css_processing.add": "Add Strip Processing Template", - "css_processing.edit": "Edit Strip Processing Template", - "css_processing.name": "Template Name:", - "css_processing.name_placeholder": "My Strip Processing Template", - "css_processing.description_label": "Description (optional):", - "css_processing.description_placeholder": "Describe this template...", - "css_processing.created": "Strip processing template created", - "css_processing.updated": "Strip processing template updated", - "css_processing.deleted": "Strip processing template deleted", - "css_processing.delete.confirm": "Are you sure you want to delete this strip processing template?", - "css_processing.error.required": "Please fill in all required fields", - "css_processing.error.load": "Error loading strip processing template", - "css_processing.error.delete": "Error deleting strip processing template", - "css_processing.error.clone_failed": "Failed to clone strip processing template", - "device.button.stream_selector": "Source Settings", - "device.stream_settings.title": "Source Settings", - "device.stream_selector.label": "Source:", - "device.stream_selector.hint": "Select a source that defines what this device captures and processes", - "device.stream_selector.none": "-- No source assigned --", - "device.stream_selector.saved": "Source settings updated", - "device.stream_settings.border_width": "Border Width (px):", - "device.stream_settings.border_width_hint": "How many pixels from the screen edge to sample for LED colors (1-100)", - "device.stream_settings.interpolation": "Interpolation Mode:", - "device.stream_settings.interpolation.average": "Average", - "device.stream_settings.interpolation.median": "Median", - "device.stream_settings.interpolation.dominant": "Dominant", - "device.stream_settings.interpolation_hint": "How to calculate LED color from sampled pixels", - "device.stream_settings.smoothing": "Smoothing:", - "device.stream_settings.smoothing_hint": "Temporal blending between frames (0=none, 1=full). Reduces flicker.", - "device.tip.stream_selector": "Configure picture source and LED projection settings for this device", - "streams.group.static_image": "Static Image", - "streams.add.static_image": "Add Static Image Source", - "streams.edit.static_image": "Edit Static Image Source", - "streams.type.static_image": "Static Image", - "streams.group.video": "Video", - "streams.add.video": "Add Video Source", - "streams.edit.video": "Edit Video Source", - "picture_source.type.video": "Video", - "picture_source.type.video.desc": "Stream frames from an uploaded video asset", - "picture_source.video.loop": "Loop:", - "picture_source.video.speed": "Playback Speed:", - "picture_source.video.start_time": "Start Time (s):", - "picture_source.video.end_time": "End Time (s):", - "picture_source.video.resolution_limit": "Max Width (px):", - "picture_source.video.resolution_limit.hint": "Downscale video at decode time for performance", - "streams.image_asset": "Image Asset:", - "streams.image_asset.select": "Select image asset…", - "streams.image_asset.search": "Search image assets…", - "streams.video_asset": "Video Asset:", - "streams.video_asset.select": "Select video asset…", - "streams.video_asset.search": "Search video assets…", - "targets.title": "Channels", - "targets.description": "Targets bridge color strip sources to output devices. Each target references a device and a color strip source.", - "targets.subtab.wled": "LED", - "targets.subtab.led": "LED", - "targets.section.devices": "Devices", - "targets.section.color_strips": "Color Strip Sources", - "targets.section.targets": "Targets", - "targets.section.specific_settings": "Specific Settings", - "targets.section.advanced": "Advanced", - "targets.add": "Add Target", - "targets.edit": "Edit Target", - "targets.loading": "Loading targets...", - "targets.none": "No targets configured", - "targets.failed": "Failed to load targets", - "targets.name": "Target Name:", - "targets.name.placeholder": "My Target", - "targets.device": "Device:", - "targets.device.hint": "Select the LED device to send data to", - "targets.device.none": "-- Select a device --", - "targets.color_strip_source": "Color Strip Source:", - "targets.color_strip_source.hint": "Select the color strip source that provides LED colors for this target", - "targets.no_css": "No source", - "targets.source": "Source:", - "targets.source.hint": "Which picture source to capture and process", - "targets.source.none": "-- No source assigned --", - "targets.fps": "Target FPS:", - "targets.fps.hint": "Target frames per second for capture and LED updates (1-90)", - "targets.fps.rec": "Hardware max ≈ {fps} fps ({leds} LEDs)", - "targets.border_width": "Border Width (px):", - "targets.border_width.hint": "How many pixels from the screen edge to sample for LED colors (1-100)", - "targets.interpolation": "Interpolation Mode:", - "targets.interpolation.hint": "How to calculate LED color from sampled pixels", - "targets.interpolation.average": "Average", - "targets.interpolation.median": "Median", - "targets.interpolation.dominant": "Dominant", - "targets.smoothing": "Smoothing:", - "targets.smoothing.hint": "Temporal blending between frames (0=none, 1=full). Reduces flicker.", - "targets.keepalive_interval": "Keep Alive Interval:", - "targets.keepalive_interval.hint": "How often to resend the last frame when the source is static, keeping the device in live mode (0.5-5.0s)", - "targets.created": "Target created successfully", - "targets.updated": "Target updated successfully", - "targets.deleted": "Target deleted successfully", - "targets.delete.confirm": "Are you sure you want to delete this target?", - "targets.error.load": "Failed to load targets", - "targets.error.required": "Please fill in all required fields", - "targets.error.name_required": "Please enter a target name", - "targets.error.delete": "Failed to delete target", - "targets.button.start": "Start", - "targets.button.stop": "Stop", - "targets.status.processing": "Processing", - "targets.status.idle": "Idle", - "targets.status.error": "Error", - "targets.metrics.actual_fps": "Actual FPS", - "targets.metrics.target_fps": "Target FPS", - "targets.metrics.frames": "Frames", - "targets.metrics.errors": "Errors", - "targets.section.pattern_templates": "Pattern Templates", - "pattern.add": "Add Pattern Template", - "pattern.edit": "Edit Pattern Template", - "pattern.name": "Template Name:", - "pattern.name.placeholder": "My Pattern Template", - "pattern.description_label": "Description (optional):", - "pattern.description_placeholder": "Describe this pattern...", - "pattern.rectangles": "Rectangles", - "pattern.rect.name": "Name", - "pattern.rect.x": "X", - "pattern.rect.y": "Y", - "pattern.rect.width": "W", - "pattern.rect.height": "H", - "pattern.rect.add": "Add Rectangle", - "pattern.rect.remove": "Remove", - "pattern.rect.empty": "No rectangles defined. Add at least one rectangle.", - "pattern.created": "Pattern template created successfully", - "pattern.updated": "Pattern template updated successfully", - "pattern.deleted": "Pattern template deleted successfully", - "pattern.delete.confirm": "Are you sure you want to delete this pattern template?", - "pattern.delete.referenced": "Cannot delete: this template is referenced by a target", - "pattern.error.required": "Please fill in all required fields", - "pattern.visual_editor": "Visual Editor", - "pattern.capture_bg": "Capture Background", - "pattern.source_for_bg": "Source for Background:", - "pattern.source_for_bg.none": "-- Select source --", - "pattern.delete_selected": "Delete Selected", - "pattern.name.hint": "A descriptive name for this rectangle layout", - "pattern.description.hint": "Optional notes about where or how this pattern is used", - "pattern.visual_editor.hint": "Click + buttons to add rectangles. Drag edges to resize, drag inside to move.", - "pattern.rectangles.hint": "Fine-tune rectangle positions and sizes with exact coordinates (0.0 to 1.0)", - "overlay.toggle": "Toggle screen overlay", - "overlay.button.show": "Show overlay visualization", - "overlay.button.hide": "Hide overlay visualization", - "overlay.started": "Overlay visualization started", - "overlay.stopped": "Overlay visualization stopped", - "overlay.error.start": "Failed to start overlay", - "overlay.error.stop": "Failed to stop overlay", - "sidebar.workspaces": "Workspaces", - "sidebar.load": "Load", - "sidebar.fps": "FPS", - "transport.status.ready": "Ready", - "transport.status.armed": "Armed · {n} live", - "transport.meta.uptime": "Uptime", - "transport.meta.cpu": "CPU", - "transport.meta.mem": "Mem", - "transport.meta.poll": "Poll", - "transport.meta.poll_hint": "Poll interval (click to cycle: 1s → 2s → 5s → 10s)", - "dashboard.title": "Dashboard", - "dashboard.section.targets": "Channels", - "dashboard.section.running": "Running", - "dashboard.section.stopped": "Stopped", - "dashboard.no_targets": "No targets configured", - "dashboard.uptime": "Uptime", - "dashboard.fps": "FPS", - "dashboard.errors": "Errors", - "dashboard.device": "Device", - "dashboard.stop_all": "Stop All", - "dashboard.failed": "Failed to load dashboard", - "dashboard.section.automations": "Automations", - "dashboard.section.scenes": "Scene Presets", - "dashboard.section.playlists": "Playlists", - "dashboard.section.sync_clocks": "Sync Clocks", - "dashboard.targets": "Targets", - "dashboard.section.performance": "System Performance", - "dashboard.section.integrations": "Integrations", - "dashboard.integrations.entities": "entities", - "dashboard.integrations.no_sources": "No integration sources configured", - "dashboard.perf.active_patches": "Active Patches", - "dashboard.perf.patches.empty.idle": "Ready to launch", - "dashboard.perf.patches.empty.none": "No patches yet", - "dashboard.perf.total_fps": "Total FPS", - "dashboard.perf.total_capture_fps": "Total Source FPS", - "dashboard.perf.total_capture_fps_actual": "Total Capture FPS", - "dashboard.perf.network": "Network", - "dashboard.perf.device_latency": "Device Latency", - "dashboard.perf.send_timing": "Send Timing", - "dashboard.perf.errors": "Errors", - "dashboard.perf.devices": "Devices", - "dashboard.perf.cpu": "CPU", - "dashboard.perf.ram": "RAM", - "dashboard.perf.gpu": "GPU", - "dashboard.perf.temp": "Temperature", - "dashboard.perf.temp.install_lhm": "Windows has no built-in CPU temperature API. Install LibreHardwareMonitor and enable \"Publish to WMI\" to see live readings here.", - "dashboard.perf.unavailable": "unavailable", - "dashboard.perf.color": "Chart color", - "dashboard.perf.mode.system": "System", - "dashboard.perf.mode.app": "App", - "dashboard.perf.mode.both": "Both", - "dashboard.poll_interval": "Refresh interval", - "dashboard.customize.title": "Customize Dashboard", - "dashboard.customize.presets": "Presets", - "dashboard.customize.preset.studio": "Studio", - "dashboard.customize.preset.operator": "Operator", - "dashboard.customize.preset.showrunner": "Showrunner", - "dashboard.customize.preset.diagnostics": "Diagnostics", - "dashboard.customize.preset.tv": "TV", - "dashboard.customize.modified": "Modified", - "dashboard.customize.global": "Global", - "dashboard.customize.width": "Width", - "dashboard.customize.width.full": "Full", - "dashboard.customize.width.centered": "Centered", - "dashboard.customize.width.narrow": "Narrow", - "dashboard.customize.anim": "Animations", - "dashboard.customize.anim.full": "Full", - "dashboard.customize.anim.reduced": "Reduced", - "dashboard.customize.anim.off": "Off", - "dashboard.customize.perf_mode": "Perf mode", - "dashboard.customize.sections": "Sections", - "dashboard.customize.perf_cells": "Performance Strip", - "dashboard.customize.fixed_top": "Pinned to top", - "dashboard.customize.drag_help": "Drag rows to reorder, or use the ↑/↓ buttons.", - "dashboard.customize.cell_drag_help": "Drag a row to change cell order in the Performance strip on the dashboard.", - "dashboard.customize.window": "Sample window", - "dashboard.customize.scale": "Y-axis scale", - "dashboard.customize.mode_short": "MODE", - "dashboard.customize.window_short": "WIN", - "dashboard.customize.scale_short": "SCL", - "dashboard.customize.density.comfortable": "Comfortable", - "dashboard.customize.density.compact": "Compact", - "dashboard.customize.density.dense": "Dense", - "card_mode.tooltip": "Card size", - "card_mode.comfortable": "Comfortable", - "card_mode.compact": "Compact", - "card_mode.dense": "Dense", - "card_mode.row": "List", - "dashboard.customize.collapse_default.on": "Start collapsed", - "dashboard.customize.collapse_default.off": "Start expanded", - "dashboard.customize.show": "Show", - "dashboard.customize.hide": "Hide", - "dashboard.customize.mode.inherit": "Inherit", - "dashboard.customize.mode.system": "Sys", - "dashboard.customize.mode.app": "App", - "dashboard.customize.mode.both": "Both", - "dashboard.customize.yscale.auto": "Auto", - "dashboard.customize.yscale.fixed": "Fixed", - "dashboard.customize.yscale.log": "Log", - "dashboard.customize.export": "Export", - "dashboard.customize.import": "Import", - "dashboard.customize.reset": "Reset", - "dashboard.customize.reset_confirm": "Reset dashboard layout to the Studio preset?", - "dashboard.customize.exported": "Layout exported", - "dashboard.customize.imported": "Layout imported", - "dashboard.customize.import_failed": "Failed to import layout", - "automations.title": "Automations", - "automations.empty": "No automations configured. Create one to automate scene activation.", - "automations.add": "Add Automation", - "automations.edit": "Edit Automation", - "automations.delete.confirm": "Delete automation \"{name}\"?", - "automations.section.triggers": "Triggers", - "automations.section.action": "Action", - "automations.section.deactivation": "Deactivation", - "automations.name": "Name:", - "automations.name.hint": "A descriptive name for this automation", - "automations.name.placeholder": "My Automation", - "automations.enabled": "Enabled:", - "automations.enabled.hint": "Disabled automations won't activate even when conditions are met", - "automations.rule_logic": "Rule Logic:", - "automations.rule_logic.hint": "How multiple rules are combined: ANY (OR) or ALL (AND)", - "automations.rule_logic.or": "Any rule (OR)", - "automations.rule_logic.and": "All rules (AND)", - "automations.rule_logic.or.desc": "Triggers when any rule matches", - "automations.rule_logic.and.desc": "Triggers only when all match", - "automations.rules": "Rules:", - "automations.rules.hint": "Rules that determine when this automation activates", - "automations.rules.add": "Add Rule", - "automations.rules.empty": "No rules — automation is always active when enabled", - "automations.rule.startup": "Startup", - "automations.rule.startup.desc": "On server start", - "automations.rule.startup.hint": "Activates when the server starts and stays active while enabled.", - "automations.rule.application": "Application", - "automations.rule.application.desc": "App running/focused", - "automations.rule.application.apps": "Applications:", - "automations.rule.application.apps.hint": "Process names, one per line (e.g. firefox.exe)", - "automations.rule.application.browse": "Browse", - "automations.rule.application.search": "Filter processes...", - "automations.rule.application.no_processes": "No processes found", - "automations.rule.application.match_type": "Match Type:", - "automations.rule.application.match_type.hint": "How to detect the application", - "automations.rule.application.match_type.running": "Running", - "automations.rule.application.match_type.running.desc": "Process is active", - "automations.rule.application.match_type.topmost": "Topmost", - "automations.rule.application.match_type.topmost.desc": "Foreground window", - "automations.rule.application.match_type.topmost_fullscreen": "Topmost + FS", - "automations.rule.application.match_type.topmost_fullscreen.desc": "Foreground + fullscreen", - "automations.rule.application.match_type.fullscreen": "Fullscreen", - "automations.rule.application.match_type.fullscreen.desc": "Any fullscreen app", - "automations.rule.application.apps.hint_android": "Package names, one per line (e.g. com.netflix.mediaclient)", - "automations.rule.application.search_apps": "Filter apps...", - "automations.rule.application.no_apps": "No apps found", - "automations.rule.application.usage_access_required": "Needs Usage Access. On your LedGrab TV, open the app and tap 'Grant usage access'.", - "automations.rule.time_of_day": "Time of Day", - "automations.rule.time_of_day.desc": "Time range", - "automations.rule.time_of_day.start_time": "Start Time:", - "automations.rule.time_of_day.end_time": "End Time:", - "automations.rule.time_of_day.overnight_hint": "For overnight ranges (e.g. 22:00–06:00), set start time after end time.", - "automations.rule.time_of_day.days": "Active days", - "automations.rule.time_of_day.days_hint": "Leave all unselected for every day. Overnight windows count toward the day they start on.", - "automations.rule.time_of_day.timezone": "Timezone", - "automations.rule.time_of_day.timezone.placeholder": "Server local (e.g. Europe/Berlin)", - "weekday.short.0": "Mon", - "weekday.short.1": "Tue", - "weekday.short.2": "Wed", - "weekday.short.3": "Thu", - "weekday.short.4": "Fri", - "weekday.short.5": "Sat", - "weekday.short.6": "Sun", - "automations.rule.system_idle": "System Idle", - "automations.rule.system_idle.desc": "User idle/active", - "automations.rule.system_idle.idle_minutes": "Idle Timeout (minutes):", - "automations.rule.system_idle.mode": "Trigger Mode:", - "automations.rule.system_idle.when_idle": "When idle", - "automations.rule.system_idle.when_idle.desc": "Fires once the user has been idle past the timeout", - "automations.rule.system_idle.when_active": "When active", - "automations.rule.system_idle.when_active.desc": "Fires while the user is actively using the system", - "automations.rule.display_state": "Display State", - "automations.rule.display_state.desc": "Monitor on/off", - "automations.rule.display_state.state": "Monitor State:", - "automations.rule.display_state.on": "On", - "automations.rule.display_state.off": "Off (sleeping)", - "automations.rule.mqtt": "MQTT", - "automations.rule.mqtt.desc": "MQTT message", - "automations.rule.mqtt.topic": "Topic:", - "automations.rule.mqtt.payload": "Payload:", - "automations.rule.mqtt.match_mode": "Match Mode:", - "automations.rule.mqtt.match_mode.exact": "Exact", - "automations.rule.mqtt.match_mode.contains": "Contains", - "automations.rule.mqtt.match_mode.regex": "Regex", - "automations.rule.mqtt.hint": "Activate when an MQTT topic receives a matching payload", - "automations.rule.webhook": "Webhook", - "automations.rule.webhook.desc": "HTTP callback", - "automations.rule.webhook.hint": "Activate via an HTTP call from external services (Home Assistant, IFTTT, curl, etc.)", - "automations.rule.webhook.url": "Webhook URL:", - "automations.rule.webhook.copy": "Copy", - "automations.rule.webhook.copied": "Copied!", - "automations.rule.webhook.save_first": "Save the automation first to generate a webhook URL", - "automations.scene": "Scene:", - "automations.scene.hint": "Scene preset to activate when conditions are met", - "automations.scene.search_placeholder": "Search scenes...", - "automations.scene.none_selected": "None (no scene)", - "automations.scene.none_available": "No scenes available", - "automations.deactivation_mode": "Deactivation:", - "automations.deactivation_mode.hint": "What happens when conditions stop matching", - "automations.deactivation_mode.none": "None", - "automations.deactivation_mode.none.desc": "Keep current state", - "automations.deactivation_mode.revert": "Revert", - "automations.deactivation_mode.revert.desc": "Restore previous state", - "automations.deactivation_mode.fallback_scene": "Fallback", - "automations.deactivation_mode.fallback_scene.desc": "Activate a fallback scene", - "automations.deactivation_scene": "Fallback Scene:", - "automations.deactivation_scene.hint": "Scene to activate when this automation deactivates", - "automations.status.active": "Active", - "automations.status.inactive": "Inactive", - "automations.status.disabled": "Disabled", - "automations.action.disable": "Disable", - "automations.last_activated": "Last activated", - "automations.logic.and": " AND ", - "automations.logic.or": " OR ", - "automations.logic.all": "ALL", - "automations.logic.any": "ANY", - "automations.updated": "Automation updated", - "automations.created": "Automation created", - "automations.deleted": "Automation deleted", - "automations.error.name_required": "Name is required", - "automations.error.clone_failed": "Failed to clone automation", - "automations.error.load_failed": "Failed to load automation", - "automations.error.save_failed": "Failed to save automation", - "automations.error.delete_failed": "Failed to delete automation", - "automations.error.toggle_failed": "Failed to toggle automation", - "scenes.title": "Scenes", - "scenes.add": "Capture Scene", - "scenes.edit": "Edit Scene", - "scenes.name": "Name:", - "scenes.name.hint": "A descriptive name for this scene preset", - "scenes.name.placeholder": "My Scene", - "scenes.description": "Description:", - "scenes.description.hint": "Optional description of what this scene does", - "scenes.targets": "Targets:", - "scenes.targets.hint": "Select which targets to include in this scene snapshot", - "scenes.targets.add": "Add Target", - "scenes.targets.search_placeholder": "Search targets...", - "scenes.targets.empty": "No targets selected", - "scenes.capture": "Capture", - "scenes.activate": "Activate scene", - "scenes.recapture": "Recapture current state", - "scenes.action.activate": "Activate", - "scenes.action.recapture": "Recapture", - "scenes.status.preset": "Preset", - "scenes.delete": "Delete scene", - "scenes.targets_count": "targets", - "scenes.captured": "Scene captured", - "scenes.updated": "Scene updated", - "scenes.activated": "Scene activated", - "scenes.activated_partial": "Scene partially activated", - "scenes.errors": "errors", - "scenes.recaptured": "Scene recaptured", - "scenes.deleted": "Scene deleted", - "scenes.recapture_confirm": "Recapture current state into \"{name}\"?", - "scenes.delete_confirm": "Delete scene \"{name}\"?", - "scenes.error.name_required": "Name is required", - "scenes.error.save_failed": "Failed to save scene", - "scenes.error.activate_failed": "Failed to activate scene", - "scenes.error.recapture_failed": "Failed to recapture scene", - "scenes.error.delete_failed": "Failed to delete scene", - "scenes.cloned": "Scene cloned", - "scenes.error.clone_failed": "Failed to clone scene", - "playlists.title": "Playlists", - "playlists.add": "New Playlist", - "playlists.edit": "Edit Playlist", - "playlists.name": "Name:", - "playlists.name.placeholder": "My Playlist", - "playlists.description": "Description:", - "playlists.description.hint": "Optional description of what this playlist does", - "playlists.section.playback": "Playback", - "playlists.loop": "Loop:", - "playlists.loop.hint": "Restart from the first scene after the last one; off plays through once and stops", - "playlists.shuffle": "Shuffle:", - "playlists.shuffle.hint": "Randomise the scene order at the start of every cycle", - "playlists.scenes": "Scenes:", - "playlists.scenes.hint": "The scene presets this playlist cycles through, each held for its own duration", - "playlists.scenes.add": "Add Scene", - "playlists.scenes_count": "scenes", - "playlists.scene_one": "scene", - "playlists.scene_many": "scenes", - "playlists.items.empty": "No scenes yet — add some below", - "playlists.items.search_placeholder": "Search scenes...", - "playlists.item.duration": "Seconds", - "playlists.item.move_up": "Move up", - "playlists.item.move_down": "Move down", - "playlists.item.missing": "Missing", - "playlists.chip.loop": "Loop", - "playlists.chip.shuffle": "Shuffle", - "playlists.action.start": "Start", - "playlists.action.stop": "Stop", - "playlists.start": "Start playlist", - "playlists.stop": "Stop playlist", - "playlists.status.playing": "Playing", - "playlists.status.stopped": "Stopped", - "playlists.started": "Playlist started", - "playlists.stopped": "Playlist stopped", - "playlists.created": "Playlist created", - "playlists.updated": "Playlist updated", - "playlists.deleted": "Playlist deleted", - "playlists.delete_confirm": "Delete playlist \"{name}\"?", - "playlists.error.name_required": "Name is required", - "playlists.error.save_failed": "Failed to save playlist", - "playlists.error.start_failed": "Failed to start playlist", - "playlists.error.stop_failed": "Failed to stop playlist", - "playlists.error.delete_failed": "Failed to delete playlist", - "playlists.error.no_presets": "Create a scene preset first", - "dashboard.type.led": "LED", - "dashboard.type.kc": "Key Colors", - "aria.close": "Close", - "aria.save": "Save", - "aria.cancel": "Cancel", - "aria.previous": "Previous", - "aria.next": "Next", - "aria.hint": "Show hint", - "color_strip.select_type": "Select Color Strip Type", - "color_strip.add": "Add", - "color_strip.edit": "Edit", - "color_strip.name": "Name:", - "color_strip.name.placeholder": "Wall Strip", - "color_strip.picture_source": "Picture Source:", - "color_strip.picture_source.hint": "Which screen capture source to use as input for LED color calculation", - "color_strip.fps": "Target FPS:", - "color_strip.fps.hint": "Target frames per second for LED color updates (10-90)", - "color_strip.interpolation": "Color Mode:", - "color_strip.interpolation.hint": "How to calculate LED color from sampled border pixels", - "color_strip.interpolation.average": "Average", - "color_strip.interpolation.median": "Median", - "color_strip.interpolation.dominant": "Dominant", - "color_strip.interpolation.average.desc": "Blend all sampled pixels into a smooth color", - "color_strip.interpolation.median.desc": "Pick the middle color, reducing outliers", - "color_strip.interpolation.dominant.desc": "Use the most frequent color in the sample", - "color_strip.smoothing": "Smoothing:", - "color_strip.smoothing.hint": "Temporal blending between frames (0=none, 1=full). Reduces flicker.", - "color_strip.frame_interpolation": "Frame Interpolation:", - "color_strip.frame_interpolation.hint": "Blends between consecutive captured frames to produce output at the full target FPS even when capture rate is lower. Reduces visible stepping on slow ambient transitions.", - "color_strip.color_corrections": "Color Corrections", - "color_strip.brightness": "Brightness:", - "color_strip.brightness.hint": "Output brightness multiplier (0=off, 1=unchanged, 2=double). Applied after color extraction.", - "color_strip.saturation": "Saturation:", - "color_strip.saturation.hint": "Color saturation (0=grayscale, 1=unchanged, 2=double saturation)", - "color_strip.gamma": "Gamma:", - "color_strip.gamma.hint": "Gamma correction (1=none, <1=brighter midtones, >1=darker midtones)", - "color_strip.test_device": "Test on Device:", - "color_strip.test_device.hint": "Select a device to send test pixels to when clicking edge toggles", - "color_strip.leds": "LED count", - "color_strip.led_count": "LED Count:", - "color_strip.led_count.hint": "Total number of LEDs on the physical strip. For screen sources: 0 = auto from calibration (extra LEDs not mapped to edges will be black). For single color: set to match your device LED count.", - "color_strip.created": "Color strip source created", - "color_strip.updated": "Color strip source updated", - "color_strip.deleted": "Color strip source deleted", - "color_strip.delete.confirm": "Are you sure you want to delete this color strip source?", - "color_strip.delete.referenced": "Cannot delete: this source is in use by a target", - "color_strip.error.name_required": "Please enter a name", - "color_strip.type": "Type:", - "color_strip.type.hint": "Picture Source derives LED colors from a screen capture. Single Color fills all LEDs with a single constant color. Gradient distributes a color gradient across all LEDs. Color Cycle smoothly cycles through a user-defined list of colors. Composite stacks multiple sources as blended layers. Audio Reactive drives LEDs from real-time audio input. API Input receives raw LED colors from external clients via REST or WebSocket.", - "color_strip.type.picture": "Picture Source", - "color_strip.type.picture.desc": "Colors from screen capture", - "color_strip.type.picture_advanced": "Multi-Monitor", - "color_strip.type.picture_advanced.desc": "Line-based calibration across monitors", - "color_strip.type.single_color": "Single Color", - "color_strip.type.single_color.desc": "Single solid color fill", - "color_strip.type.gradient": "Gradient", - "color_strip.type.gradient.desc": "Smooth color transition across LEDs", - "color_strip.single_color": "Color:", - "color_strip.single_color.hint": "The solid color that will be sent to all LEDs on the strip.", - "color_strip.gradient.preview": "Gradient:", - "color_strip.gradient.preview.hint": "Visual preview. Click the marker track below to add a stop. Drag markers to reposition.", - "color_strip.gradient.easing": "Easing:", - "color_strip.gradient.easing.hint": "Controls how colors blend between gradient stops.", - "color_strip.gradient.easing.linear": "Linear", - "color_strip.gradient.easing.linear.desc": "Constant-rate blending between stops", - "color_strip.gradient.easing.ease_in_out": "Smooth", - "color_strip.gradient.easing.ease_in_out.desc": "S-curve: slow start and end, fast middle", - "color_strip.gradient.easing.step": "Step", - "color_strip.gradient.easing.step.desc": "Hard jumps between colors with no blending", - "color_strip.gradient.easing.cubic": "Cubic", - "color_strip.gradient.easing.cubic.desc": "Cubic ease — accelerating blend curve", - "color_strip.gradient.stops": "Color Stops:", - "color_strip.gradient.stops.hint": "Each stop defines a color at a relative position (0.0 = start, 1.0 = end). The ↔ button adds a right-side color to create a hard edge at that stop.", - "color_strip.gradient.stops_count": "stops", - "color_strip.gradient.add_stop": "+ Add Stop", - "color_strip.gradient.position": "Position (0.0–1.0)", - "color_strip.gradient.bidir.hint": "Add a second color on the right side of this stop to create a hard edge in the gradient.", - "color_strip.gradient.min_stops": "Gradient must have at least 2 stops", - "color_strip.gradient.select": "Gradient:", - "color_strip.gradient.select.hint": "Select a gradient from the library. Create and edit gradients in the Gradients tab.", - "color_strip.gradient.error.no_gradient": "Please select a gradient", - "color_strip.gradient.preset": "Preset:", - "color_strip.gradient.preset.hint": "Load a predefined gradient palette. Selecting a preset replaces the current stops.", - "color_strip.gradient.preset.custom": "— Custom —", - "color_strip.gradient.preset.rainbow": "Rainbow", - "color_strip.gradient.preset.sunset": "Sunset", - "color_strip.gradient.preset.ocean": "Ocean", - "color_strip.gradient.preset.forest": "Forest", - "color_strip.gradient.preset.fire": "Fire", - "color_strip.gradient.preset.lava": "Lava", - "color_strip.gradient.preset.aurora": "Aurora", - "color_strip.gradient.preset.ice": "Ice", - "color_strip.gradient.preset.warm": "Warm", - "color_strip.gradient.preset.cool": "Cool", - "color_strip.gradient.preset.neon": "Neon", - "color_strip.gradient.preset.pastel": "Pastel", - "color_strip.gradient.preset.save_button": "Save as preset…", - "color_strip.gradient.preset.save_prompt": "Enter a name for this preset:", - "color_strip.gradient.preset.saved": "Preset saved", - "color_strip.gradient.preset.deleted": "Preset deleted", - "color_strip.gradient.preset.apply": "Apply", - "color_strip.animation": "Animation", - "color_strip.animation.type": "Effect:", - "color_strip.animation.type.hint": "Animation effect to apply.", - "color_strip.animation.type.none": "None (no animation effect)", - "color_strip.animation.type.none.desc": "Static colors with no animation", - "color_strip.animation.type.breathing": "Breathing", - "color_strip.animation.type.breathing.desc": "Smooth brightness fade in and out", - "color_strip.animation.type.gradient_shift": "Gradient Shift", - "color_strip.animation.type.gradient_shift.desc": "Slides the gradient along the strip", - "color_strip.animation.type.wave": "Wave", - "color_strip.animation.type.wave.desc": "Sinusoidal brightness wave moving along the strip", - "color_strip.animation.type.strobe": "Strobe", - "color_strip.animation.type.strobe.desc": "Rapid on/off flashing", - "color_strip.animation.type.sparkle": "Sparkle", - "color_strip.animation.type.sparkle.desc": "Random LEDs flash briefly", - "color_strip.animation.type.pulse": "Pulse", - "color_strip.animation.type.pulse.desc": "Sharp brightness pulse with quick fade", - "color_strip.animation.type.candle": "Candle", - "color_strip.animation.type.candle.desc": "Warm flickering candle-like glow", - "color_strip.animation.type.rainbow_fade": "Rainbow Fade", - "color_strip.animation.type.rainbow_fade.desc": "Cycles through the entire hue spectrum", - "color_strip.animation.type.noise_perturb": "Noise Perturb", - "color_strip.animation.type.noise_perturb.desc": "Perturbs gradient stop positions with organic noise each frame", - "color_strip.animation.type.hue_rotate": "Hue Rotate", - "color_strip.animation.type.hue_rotate.desc": "Smoothly rotates all pixel hues while preserving saturation and brightness", - "color_strip.animation.speed": "Speed:", - "color_strip.animation.speed.hint": "Animation speed multiplier. 1.0 ≈ one cycle per second for Breathing; higher values cycle faster.", - "color_strip.type.effect": "Effect", - "color_strip.type.effect.desc": "Procedural effects like fire, plasma, aurora", - "color_strip.type.effect.hint": "Procedural LED effects (fire, meteor, plasma, noise, aurora) generated in real time.", - "color_strip.type.composite": "Composite", - "color_strip.type.composite.desc": "Stack and blend multiple sources", - "color_strip.type.composite.hint": "Stack multiple color strip sources as layers with blend modes and opacity.", - "color_strip.type.mapped": "Mapped", - "color_strip.type.mapped.desc": "Assign sources to LED zones", - "color_strip.type.mapped.hint": "Assign different color strip sources to different LED ranges (zones). Unlike composite which blends layers, mapped places sources side-by-side.", - "color_strip.type.audio": "Audio Reactive", - "color_strip.type.audio.desc": "LEDs driven by audio input", - "color_strip.type.audio.hint": "LED colors driven by real-time audio input — system audio or microphone.", - "color_strip.type.api_input": "API Input", - "color_strip.type.api_input.desc": "Receive colors from external apps", - "color_strip.type.api_input.hint": "Receives raw LED color arrays from external clients via REST POST or WebSocket. Use this to integrate with custom software, home automation, or any system that can send HTTP requests.", - "color_strip.api_input.fallback_color": "Fallback Color:", - "color_strip.api_input.fallback_color.hint": "Color to display when no data has been received within the timeout period. LEDs will show this color on startup and after the connection is lost.", - "color_strip.api_input.timeout": "Timeout (seconds):", - "color_strip.api_input.timeout.hint": "How long to wait for new color data before reverting to the fallback color. Set to 0 to never time out.", - "color_strip.api_input.endpoints": "Push Endpoints:", - "color_strip.api_input.endpoints.hint": "Use these URLs to push LED color data from your external application. REST accepts JSON, WebSocket accepts both JSON and raw binary frames.", - "color_strip.api_input.save_first": "Save the source first to see the push endpoint URLs.", - "color_strip.api_input.interpolation": "LED Interpolation:", - "color_strip.api_input.interpolation.hint": "How to resize incoming LED data when its count differs from the device's LED count. Linear gives smooth blending, Nearest preserves sharp edges, None truncates or zero-pads.", - "color_strip.api_input.interpolation.linear": "Linear", - "color_strip.api_input.interpolation.linear.desc": "Smooth blending between LEDs", - "color_strip.api_input.interpolation.nearest": "Nearest", - "color_strip.api_input.interpolation.nearest.desc": "Sharp edges, no blending", - "color_strip.api_input.interpolation.none": "None", - "color_strip.api_input.interpolation.none.desc": "Truncate or zero-pad", - "color_strip.type.notification": "Notification", - "color_strip.type.notification.desc": "One-shot effect on webhook trigger", - "color_strip.type.notification.hint": "Fires a one-shot visual effect (flash, pulse, sweep) when triggered via a webhook. Designed for use as a composite layer over a persistent base source.", - "color_strip.notification.os_listener": "Listen to OS Notifications:", - "color_strip.notification.os_listener.hint": "When enabled, this source automatically fires when a desktop notification appears (Windows toast / Linux D-Bus). Requires the app to have notification access permission.", - "color_strip.notification.effect": "Effect:", - "color_strip.notification.effect.hint": "Visual effect when a notification fires. Flash fades linearly, Pulse uses a smooth bell curve, Sweep fills LEDs left-to-right then fades.", - "color_strip.notification.effect.flash": "Flash", - "color_strip.notification.effect.flash.desc": "Instant on, linear fade-out", - "color_strip.notification.effect.pulse": "Pulse", - "color_strip.notification.effect.pulse.desc": "Smooth bell-curve glow", - "color_strip.notification.effect.sweep": "Sweep", - "color_strip.notification.effect.sweep.desc": "Fills left-to-right then fades", - "color_strip.notification.effect.chase": "Chase", - "color_strip.notification.effect.chase.desc": "Light travels across strip and bounces back", - "color_strip.notification.effect.gradient_flash": "Gradient Flash", - "color_strip.notification.effect.gradient_flash.desc": "Bright center fades to edges, then all fades out", - "color_strip.notification.duration": "Duration (ms):", - "color_strip.notification.duration.hint": "How long the notification effect plays, in milliseconds.", - "color_strip.notification.default_color": "Default Color:", - "color_strip.notification.default_color.hint": "Color used when the notification has no app-specific color mapping.", - "color_strip.notification.filter_mode": "App Filter:", - "color_strip.notification.filter_mode.hint": "Filter notifications by app name. Off = accept all, Whitelist = only listed apps, Blacklist = all except listed apps.", - "color_strip.notification.filter_mode.off": "Off", - "color_strip.notification.filter_mode.whitelist": "Whitelist", - "color_strip.notification.filter_mode.blacklist": "Blacklist", - "color_strip.notification.filter_mode.off.desc": "Accept all notifications", - "color_strip.notification.filter_mode.whitelist.desc": "Only listed apps", - "color_strip.notification.filter_mode.blacklist.desc": "All except listed apps", - "color_strip.notification.filter_list": "App List:", - "color_strip.notification.filter_list.hint": "One app name per line. Use Browse to pick from running processes.", - "color_strip.notification.filter_list.placeholder": "Discord\nSlack\nTelegram", - "color_strip.notification.app_colors": "App Colors", - "color_strip.notification.app_colors.label": "Color Mappings:", - "color_strip.notification.app_colors.hint": "Per-app color overrides. Each row maps an app name to a specific notification color.", - "color_strip.notification.app_colors.add": "+ Add Mapping", - "color_strip.notification.app_overrides": "Per-App Overrides", - "color_strip.notification.app_overrides.label": "App Overrides:", - "color_strip.notification.app_overrides.hint": "Per-app overrides for color and sound. Each row can set a custom color, sound asset, and volume for a specific app.", - "color_strip.notification.app_overrides.add": "+ Add Override", - "color_strip.notification.app_overrides.app_placeholder": "App name", - "color_strip.notification.sound": "Sound", - "color_strip.notification.sound.asset": "Sound Asset:", - "color_strip.notification.sound.asset.hint": "Pick a sound asset to play when a notification fires. Leave empty for silent.", - "color_strip.notification.sound.none": "None (silent)", - "color_strip.notification.sound.search": "Search sounds…", - "color_strip.notification.sound.volume": "Volume:", - "color_strip.notification.sound.volume.hint": "Global volume for notification sounds (0–100%).", - "color_strip.notification.sound.app_sounds": "Per-App Sounds:", - "color_strip.notification.sound.app_sounds.hint": "Override sound and volume for specific apps. Empty sound = mute that app.", - "color_strip.notification.sound.app_sounds.add": "+ Add Override", - "color_strip.notification.sound.app_name_placeholder": "App name", - "color_strip.notification.endpoint": "Webhook Endpoint:", - "color_strip.notification.endpoint.hint": "Use this URL to trigger notifications from external systems. POST with optional JSON body: {\"app\": \"AppName\", \"color\": \"#FF0000\"}.", - "color_strip.notification.save_first": "Save the source first to see the webhook endpoint URL.", - "color_strip.notification.app_count": "apps", - "color_strip.notification.test": "Test notification", - "color_strip.notification.test.ok": "Notification sent", - "color_strip.notification.test.no_streams": "No running streams for this source", - "color_strip.notification.test.error": "Failed to send notification", - "color_strip.notification.history.title": "Notification History", - "color_strip.notification.history.hint": "Recent OS notifications captured by the listener (newest first). Up to 50 entries.", - "color_strip.notification.history.empty": "No notifications captured yet", - "color_strip.notification.history.unavailable": "OS notification listener is not available on this platform", - "color_strip.notification.history.error": "Failed to load notification history", - "color_strip.notification.history.refresh": "Refresh", - "color_strip.notification.history.unknown_app": "Unknown app", - "color_strip.notification.history.fired": "Streams triggered", - "color_strip.notification.history.filtered": "Streams filtered", - "color_strip.test.title": "Test Preview", - "color_strip.test.connecting": "Connecting...", - "color_strip.test.error": "Failed to connect to preview stream", - "color_strip.test.led_count": "LEDs:", - "color_strip.test.fps": "FPS:", - "color_strip.test.receive_fps": "Receive FPS", - "color_strip.test.apply": "Apply", - "color_strip.test.composite": "Composite", - "color_strip.preview.title": "Live Preview", - "color_strip.preview.not_connected": "Not connected", - "color_strip.preview.connecting": "Connecting...", - "color_strip.preview.connected": "Connected", - "color_strip.preview.unsupported": "Preview not available for this source type", - "color_strip.preview.save_first": "Save the source first — calibration is needed for preview", - "color_strip.type.daylight": "Daylight Cycle", - "color_strip.type.daylight.desc": "Simulates natural daylight over 24 hours", - "color_strip.type.daylight.hint": "Simulates the sun's color temperature throughout a 24-hour day/night cycle — from warm sunrise to cool daylight to warm sunset and dim night.", - "color_strip.daylight.speed": "Speed:", - "color_strip.daylight.speed.hint": "Cycle speed multiplier. 1.0 = full day/night cycle in ~4 minutes. Higher values cycle faster.", - "color_strip.daylight.use_real_time": "Use Real Time:", - "color_strip.daylight.use_real_time.hint": "When enabled, LED color matches the actual time of day on this computer. Speed setting is ignored.", - "color_strip.daylight.real_time": "Real Time", - "color_strip.daylight.latitude": "Latitude:", - "color_strip.daylight.latitude.hint": "Your geographic latitude (-90 to 90). Affects sunrise/sunset timing in real-time mode.", - "color_strip.daylight.longitude": "Longitude:", - "color_strip.daylight.longitude.hint": "Your geographic longitude (-180 to 180). Adjusts solar noon offset for accurate sunrise/sunset timing.", - "color_strip.type.candlelight": "Candlelight", - "color_strip.type.candlelight.desc": "Realistic flickering candle simulation", - "color_strip.type.candlelight.hint": "Simulates realistic candle flickering across all LEDs with warm tones and organic flicker patterns.", - "color_strip.type.key_colors": "Key Colors", - "color_strip.type.key_colors.desc": "Extract colors from screen regions", - "color_strip.key_colors.picture_source": "Picture Source:", - "color_strip.key_colors.interpolation": "Color Mode:", - "color_strip.key_colors.smoothing": "Smoothing:", - "color_strip.key_colors.brightness": "Brightness:", - "color_strip.key_colors.rectangles": "Screen Regions:", - "color_strip.key_colors.no_rects": "No regions defined. Click Configure Regions to add.", - "color_strip.key_colors.configure_regions": "Configure Regions", - "color_strip.key_colors.mode.average": "Average", - "color_strip.key_colors.mode.average.desc": "Mean color of all pixels in the region", - "color_strip.key_colors.mode.median": "Median", - "color_strip.key_colors.mode.median.desc": "Median color (less affected by outliers)", - "color_strip.key_colors.mode.dominant": "Dominant", - "color_strip.key_colors.mode.dominant.desc": "Most frequent color (K-means clustering)", - "color_strip.key_colors.error.no_source": "Picture source is required", - "color_strip.key_colors.error.no_rects": "At least one screen region is required", - "color_strip.type.weather": "Weather", - "color_strip.type.weather.desc": "Weather-reactive ambient colors", - "color_strip.type.weather.hint": "Maps real-time weather conditions to ambient LED colors. Requires a Weather Source entity.", - "color_strip.weather.source": "Weather Source:", - "color_strip.weather.source.hint": "The weather data source to use for ambient colors. Create one in the Weather tab first.", - "color_strip.weather.speed": "Animation Speed:", - "color_strip.weather.speed.hint": "Speed of the ambient color drift animation. Higher = faster movement.", - "color_strip.weather.temperature_influence": "Temperature Influence:", - "color_strip.weather.temperature_influence.hint": "How much the current temperature shifts the palette warm/cool. 0 = pure condition colors, 1 = strong shift.", - "color_strip.weather.error.no_source": "Please select a weather source", - "color_strip.candlelight.color": "Base Color:", - "color_strip.candlelight.color.hint": "The warm base color of the candle flame. Default is a natural warm amber.", - "color_strip.candlelight.intensity": "Flicker Intensity:", - "color_strip.candlelight.intensity.hint": "How much the candles flicker. Low values produce a gentle glow, high values simulate a windy candle.", - "color_strip.candlelight.num_candles_label": "Number of Candles:", - "color_strip.candlelight.num_candles": "candles", - "color_strip.candlelight.num_candles.hint": "How many independent candle sources along the strip. Each flickers with its own pattern.", - "color_strip.candlelight.speed": "Flicker Speed:", - "color_strip.candlelight.speed.hint": "Speed of the flicker animation. Higher values produce faster, more restless flames.", - "color_strip.candlelight.wind": "Wind:", - "color_strip.candlelight.wind.hint": "Wind simulation strength. Higher values create correlated gusts that make all candles flicker together.", - "color_strip.candlelight.type": "Candle Type:", - "color_strip.candlelight.type.hint": "Preset that adjusts flicker behavior without changing other settings.", - "color_strip.candlelight.type.default": "Default", - "color_strip.candlelight.type.default.desc": "Standard candle flicker", - "color_strip.candlelight.type.taper": "Taper", - "color_strip.candlelight.type.taper.desc": "Tall, steady candle with reduced flicker", - "color_strip.candlelight.type.votive": "Votive", - "color_strip.candlelight.type.votive.desc": "Small, flickery candle with narrow glow", - "color_strip.candlelight.type.bonfire": "Bonfire", - "color_strip.candlelight.type.bonfire.desc": "Large, chaotic fire with extra warmth", - "color_strip.type.processed": "Processed", - "color_strip.type.processed.desc": "Apply a processing template to another source", - "color_strip.type.processed.hint": "Wraps an existing color strip source and pipes its output through a filter chain.", - "color_strip.processed.input": "Source:", - "color_strip.processed.input.hint": "The color strip source whose output will be processed", - "color_strip.processed.template": "Processing Template:", - "color_strip.processed.template.hint": "Filter chain to apply to the input source output", - "color_strip.processed.error.no_input": "Please select an input source", - "color_strip.composite.layers": "Layers:", - "color_strip.composite.layers.hint": "Stack multiple color strip sources. First layer is the bottom, last is the top. Each layer can have its own blend mode and opacity.", - "color_strip.composite.add_layer": "Add Layer", - "color_strip.composite.source": "Source", - "color_strip.composite.blend_mode": "Blend", - "color_strip.composite.blend_mode.normal": "Normal", - "color_strip.composite.blend_mode.normal.desc": "Standard alpha blending", - "color_strip.composite.blend_mode.add": "Add", - "color_strip.composite.blend_mode.add.desc": "Brightens by adding colors", - "color_strip.composite.blend_mode.multiply": "Multiply", - "color_strip.composite.blend_mode.multiply.desc": "Darkens by multiplying colors", - "color_strip.composite.blend_mode.screen": "Screen", - "color_strip.composite.blend_mode.screen.desc": "Brightens, inverse of multiply", - "color_strip.composite.blend_mode.override": "Override", - "color_strip.composite.blend_mode.overlay": "Overlay", - "color_strip.composite.blend_mode.overlay.desc": "Multiply darks, screen lights", - "color_strip.composite.blend_mode.soft_light": "Soft Light", - "color_strip.composite.blend_mode.soft_light.desc": "Gentle contrast adjustment", - "color_strip.composite.blend_mode.hard_light": "Hard Light", - "color_strip.composite.blend_mode.hard_light.desc": "Strong contrast, vivid colors", - "color_strip.composite.blend_mode.difference": "Difference", - "color_strip.composite.blend_mode.difference.desc": "Absolute color difference", - "color_strip.composite.blend_mode.exclusion": "Exclusion", - "color_strip.composite.blend_mode.exclusion.desc": "Like difference, lower contrast", - "color_strip.composite.blend_mode.override.desc": "Black = transparent, bright = opaque", - "color_strip.composite.opacity": "Opacity", - "color_strip.composite.brightness": "Brightness", - "color_strip.composite.brightness.none": "None (full brightness)", - "color_strip.composite.processing": "Processing", - "color_strip.composite.enabled": "Enabled", - "color_strip.composite.error.min_layers": "At least 1 layer is required", - "color_strip.composite.error.no_source": "Each layer must have a source selected", - "color_strip.composite.layers_count": "layers", - "color_strip.composite.range": "LED Range", - "color_strip.composite.range_start": "Start", - "color_strip.composite.range_end": "End", - "color_strip.composite.reverse": "Reverse", - "color_strip.mapped.zones": "Zones:", - "color_strip.mapped.zones.hint": "Each zone maps a color strip source to a specific LED range. Zones are placed side-by-side — gaps between zones stay black.", - "color_strip.mapped.add_zone": "+ Add Zone", - "color_strip.mapped.zone_source": "Source", - "color_strip.mapped.zone_start": "Start LED", - "color_strip.mapped.zone_end": "End LED", - "color_strip.mapped.zone_reverse": "Reverse", - "color_strip.mapped.zones_count": "zones", - "color_strip.mapped.select_source": "Search sources...", - "color_strip.mapped.error.no_source": "Each zone must have a source selected", - "color_strip.audio.visualization": "Visualization:", - "color_strip.audio.visualization.hint": "How audio data is rendered to LEDs.", - "color_strip.audio.viz.spectrum": "Spectrum Analyzer", - "color_strip.audio.viz.spectrum.desc": "Frequency bars across the strip", - "color_strip.audio.viz.beat_pulse": "Beat Pulse", - "color_strip.audio.viz.beat_pulse.desc": "All LEDs pulse on the beat", - "color_strip.audio.viz.vu_meter": "VU Meter", - "color_strip.audio.viz.vu_meter.desc": "Volume level fills the strip", - "color_strip.audio.viz.pulse_on_beat": "Pulse on Beat", - "color_strip.audio.viz.pulse_on_beat.desc": "LEDs pulse with each detected beat", - "color_strip.audio.viz.energy_gradient": "Energy Gradient", - "color_strip.audio.viz.energy_gradient.desc": "Gradient intensity follows audio energy", - "color_strip.audio.viz.spectrum_bands": "Spectrum Bands", - "color_strip.audio.viz.spectrum_bands.desc": "Grouped frequency bands across the strip", - "color_strip.audio.viz.strobe_on_drop": "Strobe on Drop", - "color_strip.audio.viz.strobe_on_drop.desc": "Flash strobe effect on bass drops", - "color_strip.audio.beat_decay": "Beat Decay:", - "color_strip.audio.beat_decay.hint": "How quickly the beat pulse fades. Lower values = longer fade, higher = snappier response.", - "color_strip.audio.source": "Audio Source:", - "color_strip.audio.source.hint": "Audio source for this visualization. Can be a multichannel (device) or mono (single channel) source. Create and manage audio sources in the Sources tab.", - "color_strip.audio.sensitivity": "Sensitivity:", - "color_strip.audio.sensitivity.hint": "Gain multiplier for audio levels. Higher values make LEDs react to quieter sounds.", - "color_strip.audio.smoothing": "Smoothing:", - "color_strip.audio.smoothing.hint": "Temporal smoothing between frames. Higher values produce smoother but slower-reacting visuals.", - "color_strip.audio.palette": "Palette:", - "color_strip.audio.palette.hint": "Color palette used for spectrum bars or beat pulse coloring.", - "color_strip.audio.color": "Base Color:", - "color_strip.audio.color.hint": "Low-level color for VU meter bar.", - "color_strip.audio.color_peak": "Peak Color:", - "color_strip.audio.color_peak.hint": "High-level color at the top of the VU meter bar.", - "color_strip.audio.mirror": "Mirror:", - "color_strip.audio.mirror.hint": "Mirror spectrum from center outward: bass in the middle, treble at the edges.", - "color_strip.effect.type": "Effect Type:", - "color_strip.effect.type.hint": "Choose the procedural algorithm.", - "color_strip.effect.fire": "Fire", - "color_strip.effect.fire.desc": "Cellular automaton simulating rising flames with heat diffusion", - "color_strip.effect.meteor": "Meteor", - "color_strip.effect.meteor.desc": "Bright head travels along the strip with an exponential-decay tail", - "color_strip.effect.plasma": "Plasma", - "color_strip.effect.plasma.desc": "Overlapping sine waves mapped to a palette — classic demo-scene effect", - "color_strip.effect.noise": "Noise", - "color_strip.effect.noise.desc": "Scrolling fractal value noise mapped to a palette", - "color_strip.effect.aurora": "Aurora", - "color_strip.effect.aurora.desc": "Layered noise bands that drift and blend — northern lights style", - "color_strip.effect.rain": "Rain", - "color_strip.effect.rain.desc": "Raindrops fall down the strip with trailing tails", - "color_strip.effect.comet": "Comet", - "color_strip.effect.comet.desc": "Multiple comets with curved, pulsing tails", - "color_strip.effect.bouncing_ball": "Bouncing Ball", - "color_strip.effect.bouncing_ball.desc": "Physics-simulated balls bouncing with gravity", - "color_strip.effect.fireworks": "Fireworks", - "color_strip.effect.fireworks.desc": "Rockets launch and explode into colorful bursts", - "color_strip.effect.sparkle_rain": "Sparkle Rain", - "color_strip.effect.sparkle_rain.desc": "Twinkling star field with smooth fade-in/fade-out", - "color_strip.effect.lava_lamp": "Lava Lamp", - "color_strip.effect.lava_lamp.desc": "Slow-moving colored blobs that merge and separate", - "color_strip.effect.wave_interference": "Wave Interference", - "color_strip.effect.wave_interference.desc": "Two counter-propagating waves creating interference patterns", - "color_strip.effect.custom_palette": "Custom Palette:", - "color_strip.effect.custom_palette.hint": "JSON array of [position, R, G, B] stops, e.g. [[0,0,0,0],[0.5,255,0,0],[1,255,255,0]]", - "color_strip.effect.speed": "Speed:", - "color_strip.effect.speed.hint": "Speed multiplier for the effect animation (0.1 = very slow, 10.0 = very fast).", - "color_strip.effect.palette": "Palette:", - "color_strip.effect.palette.hint": "Color palette used to map effect values to RGB colors.", - "color_strip.effect.color": "Meteor Color:", - "color_strip.effect.color.hint": "Head color for the meteor effect.", - "color_strip.effect.intensity": "Intensity:", - "color_strip.effect.intensity.hint": "Effect intensity — controls spark rate (fire), tail decay (meteor), or brightness range (aurora).", - "color_strip.effect.scale": "Scale:", - "color_strip.effect.scale.hint": "Spatial scale — wave frequency (plasma), zoom level (noise), or band width (aurora).", - "color_strip.effect.mirror": "Mirror:", - "color_strip.effect.mirror.hint": "Bounce mode — the meteor reverses direction at strip ends instead of wrapping.", - "color_strip.palette.fire": "Fire", - "color_strip.palette.ocean": "Ocean", - "color_strip.palette.lava": "Lava", - "color_strip.palette.forest": "Forest", - "color_strip.palette.rainbow": "Rainbow", - "color_strip.palette.aurora": "Aurora", - "color_strip.palette.sunset": "Sunset", - "color_strip.palette.ice": "Ice", - "color_strip.palette.custom": "Custom", - "audio_source.title": "Audio Sources", - "audio_source.add": "Add Audio Source", - "audio_source.add.capture": "Add Capture Source", - "audio_source.add.processed": "Add Processed Source", - "audio_source.edit": "Edit Audio Source", - "audio_source.edit.capture": "Edit Capture Source", - "audio_source.edit.processed": "Edit Processed Source", - "audio_source.name": "Name:", - "audio_source.name.placeholder": "System Audio", - "audio_source.name.hint": "A descriptive name for this audio source", - "audio_source.type": "Type:", - "audio_source.type.hint": "Capture wraps a physical audio device. Processed applies audio processing filters to another source.", - "audio_source.type.capture": "Capture", - "audio_source.type.processed": "Processed", - "audio_source.device": "Audio Device:", - "audio_source.device.hint": "Audio input source. Loopback devices capture system audio output; input devices capture microphone or line-in.", - "audio_source.refresh_devices": "Refresh devices", - "audio_source.parent": "Input Audio Source:", - "audio_source.parent.hint": "Audio source to apply processing filters to", - "audio_source.processing_template": "Processing Template:", - "audio_source.processing_template.hint": "Audio processing template with filters to apply to the input source", - "audio_source.description": "Description (optional):", - "audio_source.description.placeholder": "Describe this audio source...", - "audio_source.description.hint": "Optional notes about this audio source", - "audio_source.created": "Audio source created", - "audio_source.updated": "Audio source updated", - "audio_source.deleted": "Audio source deleted", - "audio_source.delete.confirm": "Are you sure you want to delete this audio source?", - "audio_source.error.name_required": "Please enter a name", - "audio_source.audio_template": "Audio Template:", - "audio_source.audio_template.hint": "Audio capture template that defines which engine and settings to use for this device", - "audio_source.test": "Test", - "audio_source.test.title": "Test Audio Source", - "audio_source.test.rms": "RMS", - "audio_source.test.peak": "Peak", - "audio_source.test.beat": "Beat", - "audio_source.test.connecting": "Connecting...", - "audio_source.test.error": "Audio test failed", - "audio_template.test": "Test", - "audio_template.test.title": "Test Audio Template", - "audio_template.test.device": "Audio Device:", - "audio_template.test.device.hint": "Select which audio device to capture from during the test", - "audio_template.test.run": "Run", - "audio_template.title": "Audio Templates", - "audio_template.add": "Add Audio Template", - "audio_template.edit": "Edit Audio Template", - "audio_template.name": "Template Name:", - "audio_template.name.placeholder": "My Audio Template", - "audio_template.description.label": "Description (optional):", - "audio_template.description.placeholder": "Describe this template...", - "audio_template.engine": "Audio Engine:", - "audio_template.engine.hint": "Select the audio capture backend to use. WASAPI is Windows-only with loopback support. Sounddevice is cross-platform.", - "audio_template.engine.unavailable": "Unavailable", - "audio_template.engine.unavailable.hint": "This engine is not available on your system", - "audio_template.config": "Configuration", - "audio_template.config.show": "Show configuration", - "audio_template.created": "Audio template created", - "audio_template.updated": "Audio template updated", - "audio_template.deleted": "Audio template deleted", - "audio_template.delete.confirm": "Are you sure you want to delete this audio template?", - "audio_template.error.load": "Failed to load audio templates", - "audio_template.error.engines": "Failed to load audio engines", - "audio_template.error.required": "Please fill in all required fields", - "audio_template.error.delete": "Failed to delete audio template", - "audio_template.error.save_failed": "Failed to save audio template", - "audio_template.error.load_failed": "Failed to load audio template", - "streams.group.value": "Value Sources", - "streams.group.sync": "Sync Clocks", - "streams.group.gradients": "Gradients", - "gradient.group.title": "Gradients", - "gradient.add": "Add Gradient", - "gradient.edit": "Edit Gradient", - "gradient.builtin": "Built-in", - "gradient.stops_label": "stops", - "gradient.name": "Name:", - "gradient.name.hint": "A descriptive name for this gradient.", - "gradient.description": "Description:", - "gradient.description.hint": "Optional description for this gradient.", - "gradient.created": "Gradient created", - "gradient.updated": "Gradient updated", - "gradient.cloned": "Gradient cloned", - "gradient.deleted": "Gradient deleted", - "gradient.error.name_required": "Name is required", - "gradient.error.min_stops": "At least 2 color stops are required", - "gradient.error.delete_failed": "Failed to delete gradient", - "gradient.error.save_failed": "Failed to save gradient", - "gradient.create_name": "New gradient name:", - "gradient.edit_name": "Rename gradient:", - "gradient.confirm_delete": "Delete gradient \"{name}\"?", - "section.empty.gradients": "No gradients yet", - "tree.group.picture": "Picture Source", - "tree.group.capture": "Screen Capture", - "tree.group.static": "Static", - "tree.group.processing": "Processed", - "tree.group.strip": "Color Strip", - "tree.group.audio": "Audio", - "tree.group.audio_capture": "Capture", - "tree.group.audio_processed": "Processed", - "tree.group.integrations": "Integrations", - "tree.group.utility": "Utility", - "tree.leaf.sources": "Sources", - "tree.leaf.engine_templates": "Engine Templates", - "tree.leaf.images": "Images", - "tree.leaf.video": "Video", - "tree.leaf.filter_templates": "Filter Templates", - "tree.leaf.processing_templates": "Processing Templates", - "tree.leaf.templates": "Templates", - "value_source.group.title": "Value Sources", - "value_source.select_type": "Select Value Source Type", - "value_source.add": "Add Value Source", - "value_source.edit": "Edit Value Source", - "value_source.name": "Name:", - "value_source.name.placeholder": "Brightness Pulse", - "value_source.name.hint": "A descriptive name for this value source", - "value_source.type": "Type:", - "value_source.type.hint": "Static outputs a constant value. Animated cycles through a waveform. Audio reacts to sound input. Adaptive types adjust brightness automatically based on time of day or scene content.", - "value_source.type.static": "Static", - "value_source.type.static.desc": "Constant output value", - "value_source.type.animated": "Animated", - "value_source.type.animated.desc": "Cycles through a waveform", - "value_source.type.audio": "Audio", - "value_source.type.audio.desc": "Reacts to sound input", - "value_source.type.adaptive_time": "Adaptive (Time)", - "value_source.type.adaptive_time.desc": "Adjusts by time of day", - "value_source.type.adaptive_scene": "Adaptive (Scene)", - "value_source.type.adaptive_scene.desc": "Adjusts by scene content", - "value_source.type.daylight": "Daylight Cycle", - "value_source.type.daylight.desc": "Value follows day/night cycle", - "value_source.type.static_color": "Static Color", - "value_source.type.static_color.desc": "Fixed RGB color", - "value_source.type.animated_color": "Animated Color", - "value_source.type.animated_color.desc": "Cycles through colors", - "value_source.type.adaptive_time_color": "Time Color", - "value_source.type.adaptive_time_color.desc": "24-hour color schedule", - "value_source.type.ha_entity": "HA Entity", - "value_source.type.ha_entity.desc": "Reads value from a Home Assistant sensor", - "value_source.type.gradient_map": "Gradient Map", - "value_source.type.gradient_map.desc": "Maps numeric value through a color gradient", - "value_source.type.css_extract": "Strip Extract", - "value_source.type.css_extract.desc": "Extracts color from a color strip source", - "value_source.type.system_metrics": "System Metrics", - "value_source.type.system_metrics.desc": "Monitor CPU, GPU, RAM, disk, network, battery, or fan speed", - "value_source.metric": "Metric:", - "value_source.metric.hint": "System metric to monitor. Some metrics may be unavailable depending on hardware.", - "value_source.metric.cpu_load": "CPU Load", - "value_source.metric.cpu_temp": "CPU Temperature", - "value_source.metric.gpu_load": "GPU Load", - "value_source.metric.gpu_temp": "GPU Temperature", - "value_source.metric.ram_usage": "RAM Usage", - "value_source.metric.disk_usage": "Disk Usage", - "value_source.metric.network_rx": "Network RX", - "value_source.metric.network_tx": "Network TX", - "value_source.metric.battery_level": "Battery Level", - "value_source.metric.fan_speed": "Fan Speed", - "value_source.sysmetric.min": "Min Value:", - "value_source.sysmetric.max": "Max Value:", - "value_source.max_rate": "Max Rate (bytes/sec):", - "value_source.max_rate.hint": "Maximum expected network rate in bytes/sec. 125000000 = 1 Gbps.", - "value_source.disk_path": "Disk Path:", - "value_source.disk_path.hint": "Disk mount point or drive letter (e.g. / or C:\\)", - "value_source.sensor_label": "Sensor Label:", - "value_source.sensor_label.hint": "Optional sensor name to pick a specific sensor (empty = first available)", - "value_source.poll_interval": "Poll Interval:", - "value_source.poll_interval.hint": "Seconds between metric reads", - "value_source.gradient_map.input": "Input Value Source", - "value_source.gradient_map.gradient": "Gradient", - "value_source.css_extract.source": "Color Strip Source", - "value_source.ha_source": "HA Connection:", - "value_source.ha_source.hint": "Home Assistant connection to read entities from", - "value_source.entity_id": "Entity:", - "value_source.entity_id.hint": "HA entity ID (e.g. sensor.temperature)", - "value_source.attribute": "Attribute (optional):", - "value_source.attribute.hint": "Read a specific attribute instead of the entity state", - "value_source.min_ha_value": "Min HA Value:", - "value_source.min_ha_value.hint": "Raw HA value that maps to 0% output", - "value_source.max_ha_value": "Max HA Value:", - "value_source.max_ha_value.hint": "Raw HA value that maps to 100% output", - "value_source.input_source": "Input Value Source:", - "value_source.input_source.hint": "Float value source (0-1) to map through the gradient", - "value_source.gradient_stops": "Gradient:", - "value_source.gradient_stops.hint": "Color stops for the gradient. Position 0 = input value 0, position 1 = input value 1", - "value_source.easing": "Interpolation:", - "value_source.easing.hint": "How colors blend between stops", - "value_source.css_source": "Color Strip Source:", - "value_source.css_source.hint": "Color strip source to extract color from", - "value_source.led_start": "LED Start:", - "value_source.led_start.hint": "First LED in the range (0-based)", - "value_source.led_end": "LED End:", - "value_source.led_end.hint": "Last LED in the range (-1 = whole strip)", - "value_source.filter.all": "All", - "value_source.filter.float": "Numeric", - "value_source.filter.color": "Color", - "value_source.static_color.color": "Color:", - "value_source.animated_color.colors": "Colors:", - "value_source.animated_color.speed": "Speed (cpm):", - "value_source.animated_color.easing": "Easing:", - "value_source.animated_color.easing.linear": "Linear", - "value_source.animated_color.easing.linear.desc": "Constant rate blend between colors", - "value_source.animated_color.easing.step": "Step", - "value_source.animated_color.easing.step.desc": "Instant jump between colors", - "value_source.animated_color.easing.ease_in": "Ease In", - "value_source.animated_color.easing.ease_in.desc": "Slow start, accelerating into the next color", - "value_source.animated_color.easing.ease_out": "Ease Out", - "value_source.animated_color.easing.ease_out.desc": "Quick start, settling into the next color", - "value_source.animated_color.easing.ease_in_out": "Ease In Out", - "value_source.animated_color.easing.ease_in_out.desc": "Smooth at both ends, faster in the middle", - "value_source.animated_color.easing.sine": "Sine", - "value_source.animated_color.easing.sine.desc": "Sinusoidal curve — natural ambient cycling", - "value_source.animated_color.color_count": "colors", - "value_source.animated_color.clock": "Animation Clock:", - "value_source.animated_color.clock.hint": "Optional sync clock — when set, the clock controls timing (its speed multiplier scales the cpm above) and pause state. Leave empty to run on the value source's own speed.", - "value_source.adaptive_time_color.schedule": "Color Schedule:", - "value_source.daylight.speed": "Speed:", - "value_source.daylight.speed.hint": "Cycle speed multiplier. 1.0 = full day/night cycle in ~4 minutes. Higher values cycle faster.", - "value_source.daylight.use_real_time": "Use Real Time:", - "value_source.daylight.use_real_time.hint": "When enabled, the value follows the actual time of day. Speed is ignored.", - "value_source.normalize": "Normalize to 0–1:", - "value_source.normalize.hint": "On: rescale the raw value to 0–1 using Min/Max. Off: the value is clamped to 0–1 as-is (for sources that already report a 0–1 fraction). The raw value stays available to templates (raw[name]) and automations.", - "value_source.daylight.enable_real_time": "Follow wall clock", - "value_source.daylight.latitude": "Latitude:", - "value_source.daylight.latitude.hint": "Your geographic latitude (-90 to 90). Steepens or flattens the sunrise/sunset edges of the cycle.", - "value_source.daylight.longitude": "Longitude:", - "value_source.daylight.longitude.hint": "Your geographic longitude (-180 to 180). Shifts solar noon for accurate sunrise/sunset alignment with your wall clock.", - "value_source.daylight.real_time": "Real-time", - "value_source.daylight.speed_label": "Speed", - "value_source.value": "Value:", - "value_source.value.hint": "Constant output value (0.0 = off, 1.0 = full brightness)", - "value_source.waveform": "Waveform:", - "value_source.waveform.hint": "Shape of the brightness animation cycle", - "value_source.waveform.sine": "Sine", - "value_source.waveform.triangle": "Triangle", - "value_source.waveform.square": "Square", - "value_source.waveform.sawtooth": "Sawtooth", - "value_source.speed": "Speed (cpm):", - "value_source.speed.hint": "Cycles per minute — how fast the waveform repeats (1 = very slow, 120 = very fast)", - "value_source.min_value": "Min Value:", - "value_source.min_value.hint": "Minimum output of the waveform cycle", - "value_source.max_value": "Max Value:", - "value_source.max_value.hint": "Maximum output of the waveform cycle", - "value_source.audio_source": "Audio Source:", - "value_source.audio_source.hint": "Audio source to read audio levels from", - "value_source.mode": "Mode:", - "value_source.mode.hint": "RMS measures average volume. Peak tracks loudest moments. Beat triggers on rhythm.", - "value_source.mode.rms": "RMS (Volume)", - "value_source.mode.peak": "Peak", - "value_source.mode.beat": "Beat", - "value_source.mode.rms.desc": "Average volume level", - "value_source.mode.peak.desc": "Loudest moment tracking", - "value_source.mode.beat.desc": "Rhythm pulse detection", - "value_source.auto_gain": "Auto Gain:", - "value_source.auto_gain.hint": "Automatically normalize audio levels so output uses the full range, regardless of input volume", - "value_source.auto_gain.enable": "Enable auto-gain", - "value_source.sensitivity": "Sensitivity:", - "value_source.sensitivity.hint": "Gain multiplier for the audio signal (higher = more reactive)", - "value_source.scene_sensitivity.hint": "Gain multiplier for the luminance signal (higher = more reactive to brightness changes)", - "value_source.smoothing": "Smoothing:", - "value_source.smoothing.hint": "Temporal smoothing (0 = instant response, 1 = very smooth/slow)", - "value_source.audio_min_value": "Min Value:", - "value_source.audio_min_value.hint": "Output when audio is silent (e.g. 0.3 = 30% brightness floor)", - "value_source.audio_max_value": "Max Value:", - "value_source.audio_max_value.hint": "Output at maximum audio level", - "value_source.schedule": "Schedule:", - "value_source.schedule.hint": "Define at least 2 time points. Brightness interpolates linearly between them, wrapping at midnight.", - "value_source.schedule.add": "+ Add Point", - "value_source.schedule.points": "points", - "value_source.picture_source": "Picture Source:", - "value_source.picture_source.hint": "The picture source whose frames will be analyzed for average brightness.", - "value_source.scene_behavior": "Behavior:", - "value_source.scene_behavior.hint": "Complement: dark scene = high brightness (ideal for ambient backlight). Match: bright scene = high brightness.", - "value_source.scene_behavior.complement": "Complement (dark → bright)", - "value_source.scene_behavior.match": "Match (bright → bright)", - "value_source.adaptive_min_value": "Min Value:", - "value_source.adaptive_min_value.hint": "Minimum output brightness", - "value_source.adaptive_max_value": "Max Value:", - "value_source.adaptive_max_value.hint": "Maximum output brightness", - "value_source.error.schedule_min": "Schedule requires at least 2 time points", - "value_source.description": "Description (optional):", - "value_source.description.placeholder": "Describe this value source...", - "value_source.description.hint": "Optional notes about this value source", - "value_source.created": "Value source created", - "value_source.updated": "Value source updated", - "value_source.deleted": "Value source deleted", - "value_source.delete.confirm": "Are you sure you want to delete this value source?", - "value_source.error.name_required": "Please enter a name", - "value_source.test": "Test", - "value_source.test.title": "Test Value Source", - "value_source.test.connecting": "Connecting...", - "value_source.test.error": "Failed to connect", - "value_source.test.current": "Current", - "value_source.test.min": "Min", - "value_source.test.max": "Max", - "test.frames": "Frames", - "test.fps": "FPS", - "test.avg_capture": "Avg", - "targets.brightness": "Brightness:", - "targets.brightness.hint": "Output brightness multiplier (0–1). Can be bound to a value source for dynamic control.", - "targets.brightness_vs": "Brightness Source:", - "targets.brightness_vs.hint": "Optional value source that dynamically controls brightness each frame (overrides device brightness)", - "targets.brightness_vs.none": "None (device brightness)", - "targets.min_brightness_threshold": "Min Brightness Threshold:", - "targets.min_brightness_threshold.hint": "Effective output brightness (pixel brightness × device/source brightness) below this value turns LEDs off completely (0 = disabled)", - "targets.adaptive_fps": "Adaptive FPS:", - "targets.adaptive_fps.hint": "Automatically reduce send rate when the device becomes unresponsive, and gradually recover when it stabilizes. Recommended for WiFi devices with weak signal.", - "targets.protocol": "Protocol:", - "targets.protocol.hint": "DDP sends pixels via fast UDP (recommended for most setups). HTTP uses the JSON API — slower but reliable, limited to ~500 LEDs.", - "targets.power_limit": "Max current (ABL):", - "targets.power_limit.hint": "Caps the strip's estimated current draw to your power-supply budget to prevent brownouts (voltage sag, color shift, flicker) on bright/white scenes. Set it to your PSU's rated current, leaving some headroom. 0 = unlimited.", - "targets.power_limit.ma_suffix": "mA (0 = unlimited)", - "targets.power_limit.per_led": "mA per LED (full white):", - "targets.protocol.ddp": "DDP (UDP)", - "targets.protocol.ddp.desc": "Fast raw UDP packets — recommended", - "targets.protocol.udp": "WLED UDP (realtime)", - "targets.protocol.udp.desc": "WLED native realtime — RGBW whites + auto-revert if the stream drops", - "targets.protocol.http": "HTTP", - "targets.protocol.http.desc": "JSON API — slower, ≤500 LEDs", - "targets.protocol.serial": "Serial", - "search.open": "Search (Ctrl+K)", - "search.placeholder": "Search entities... (Ctrl+K)", - "search.loading": "Loading...", - "search.no_results": "No results found", - "search.group.devices": "Devices", - "search.group.targets": "LED Targets", - "search.group.kc_targets": "Key Colors Targets", - "search.group.css": "Color Strip Sources", - "search.group.automations": "Automations", - "search.group.streams": "Picture Streams", - "search.group.capture_templates": "Capture Templates", - "search.group.pp_templates": "Post-Processing Templates", - "search.group.pattern_templates": "Pattern Templates", - "search.group.audio": "Audio Sources", - "search.group.value": "Value Sources", - "search.group.scenes": "Scene Presets", - "search.group.cspt": "Strip Processing Templates", - "search.group.sync_clocks": "Sync Clocks", - "search.group.actions": "Actions", - "search.action.start": "Start", - "search.action.stop": "Stop", - "search.action.activate": "Activate", - "search.action.enable": "Enable", - "search.action.disable": "Disable", - "settings.backup.label": "Backup Configuration", - "settings.backup.hint": "Download all configuration (devices, targets, streams, templates, automations) as a single JSON file.", - "settings.backup.button": "Download", - "settings.backup.success": "Backup downloaded successfully", - "settings.backup.error": "Backup download failed", - "settings.restore.label": "Restore Configuration", - "settings.restore.hint": "Upload a previously downloaded backup file to replace all configuration. The server will restart automatically.", - "settings.restore.button": "Restore", - "settings.restore.confirm": "This will replace ALL configuration and restart the server. Are you sure?", - "settings.restore.success": "Configuration restored", - "settings.restore.error": "Restore failed", - "settings.restore.restarting": "Server is restarting...", - "settings.restore.restart_timeout": "Server did not respond. Please refresh the page manually.", - "settings.restart_server": "Restart Server", - "settings.restart_confirm": "Restart the server? Active targets will be stopped.", - "settings.restarting": "Restarting server...", - "settings.button.close": "Close", - "settings.log_level.label": "Log Level", - "settings.log_level.hint": "Change the server log verbosity at runtime. DEBUG shows the most detail; CRITICAL shows only fatal errors.", - "settings.log_level.save": "Apply", - "settings.log_level.saved": "Log level changed", - "settings.log_level.save_error": "Failed to change log level", - "settings.log_level.desc.debug": "Verbose developer output", - "settings.log_level.desc.info": "Normal operation messages", - "settings.log_level.desc.warning": "Potential problems", - "settings.log_level.desc.error": "Failures only", - "settings.log_level.desc.critical": "Fatal errors only", - "settings.shutdown_action.label": "Shutdown action", - "settings.shutdown_action.hint": "What happens to LED targets when the server shuts down. \"Stop targets\" runs the normal stop sequence so devices with auto-restore restore their prior state. \"Nothing\" leaves the lights showing the last frame.", - "settings.shutdown_action.saved": "Shutdown action saved", - "settings.shutdown_action.save_error": "Failed to save shutdown action", - "settings.shutdown_action.opt.stop": "Stop targets", - "settings.shutdown_action.opt.stop_desc": "Run the normal stop sequence (per-device auto-restore applies)", - "settings.shutdown_action.opt.nothing": "Nothing", - "settings.shutdown_action.opt.nothing_desc": "Leave lights showing the last frame", - "settings.daylight_timezone.label": "Daylight timezone", - "settings.daylight_timezone.hint": "IANA timezone every \"real-time\" daylight cycle reads its wall clock in. Empty (default) uses the server's system timezone.", - "settings.daylight_timezone.saved": "Daylight timezone saved", - "settings.daylight_timezone.save_error": "Failed to save daylight timezone", - "common.daylight_tz.system": "System default", - "common.daylight_tz.system_desc": "Server clock — no override", - "common.daylight_tz.detected": "Browser-detected", - "common.daylight_tz.detected_label": "Auto-detected", - "common.daylight_tz.search": "Search timezones, cities, regions…", - "common.daylight_tz.region.utc": "Universal", - "common.daylight_tz.region.europe": "Europe", - "common.daylight_tz.region.africa": "Africa", - "common.daylight_tz.region.me": "Middle East", - "common.daylight_tz.region.asia": "Asia", - "common.daylight_tz.region.pacific": "Pacific", - "common.daylight_tz.region.americas": "Americas", - "settings.auto_backup.label": "Auto-Backup", - "settings.auto_backup.hint": "Automatically create periodic backups of all configuration. Old backups are pruned when the maximum count is reached.", - "settings.auto_backup.enable": "Enable auto-backup", - "settings.auto_backup.interval_label": "Interval", - "settings.auto_backup.max_label": "Max backups", - "settings.auto_backup.save": "Save Settings", - "settings.auto_backup.saved": "Auto-backup settings saved", - "settings.auto_backup.save_error": "Failed to save auto-backup settings", - "settings.auto_backup.backup_now": "Backup Now", - "settings.auto_backup.backup_created": "Backup created", - "settings.auto_backup.backup_error": "Backup failed", - "settings.auto_backup.last_backup": "Last backup", - "settings.auto_backup.never": "Never", - "settings.saved_backups.label": "Saved Backups", - "settings.saved_backups.hint": "Auto-backup files stored on the server. Download to save locally, or delete to free space.", - "settings.saved_backups.empty": "No saved backups", - "settings.saved_backups.restore": "Restore", - "settings.saved_backups.download": "Download", - "settings.saved_backups.delete": "Delete", - "settings.saved_backups.delete_confirm": "Delete this backup file?", - "settings.saved_backups.delete_error": "Failed to delete backup", - "settings.saved_backups.type.auto": "auto", - "settings.saved_backups.type.manual": "manual", - "settings.mqtt.label": "MQTT", - "settings.mqtt.hint": "Configure MQTT broker connection for automation conditions and triggers.", - "settings.mqtt.enabled": "Enable MQTT", - "settings.mqtt.host_label": "Broker Host", - "settings.mqtt.port_label": "Port", - "settings.mqtt.username_label": "Username", - "settings.mqtt.password_label": "Password", - "settings.mqtt.password_set_hint": "Password is set — leave blank to keep", - "settings.mqtt.client_id_label": "Client ID", - "settings.mqtt.base_topic_label": "Base Topic", - "settings.mqtt.save": "Save MQTT Settings", - "settings.mqtt.saved": "MQTT settings saved", - "settings.mqtt.save_error": "Failed to save MQTT settings", - "settings.mqtt.error_host_required": "Broker host is required", - "settings.logs.label": "Server Logs", - "settings.logs.hint": "Stream live server log output. Use the filter to show only relevant log levels.", - "settings.logs.connect": "Connect", - "settings.logs.disconnect": "Disconnect", - "settings.logs.clear": "Clear", - "settings.logs.error": "Log viewer connection failed", - "settings.logs.filter.all": "All levels", - "settings.logs.filter.info": "Info+", - "settings.logs.filter.warning": "Warning+", - "settings.logs.filter.error": "Error only", - "settings.logs.filter.all_desc": "Show all log messages", - "settings.logs.filter.info_desc": "Info, warning, and errors", - "settings.logs.filter.warning_desc": "Warnings and errors only", - "settings.logs.filter.error_desc": "Errors only", - "settings.logs.stat.lines": "LINES", - "settings.logs.stat.warn": "WARN", - "settings.logs.stat.err": "ERR", - "settings.logs.patch.idle": "STANDBY", - "settings.logs.patch.connecting": "CONNECTING", - "settings.logs.patch.live": "STREAMING", - "settings.logs.patch.error": "OFFLINE", - "settings.logs.empty.title": "Awaiting log frames", - "settings.logs.empty.sub": "Connect the WebSocket stream to begin tailing.", - "device.error.power_off_failed": "Failed to turn off device", - "device.error.remove_failed": "Failed to remove device", - "device.error.settings_load_failed": "Failed to load device settings", - "device.error.brightness": "Failed to update brightness", - "device.error.required": "Please fill in all fields correctly", - "device.error.update": "Failed to update device", - "device.error.save": "Failed to save settings", - "device.error.clone_failed": "Failed to clone device", - "device.error.load_failed": "Failed to load device", - "device_discovery.error.fill_all_fields": "Please fill in all fields", - "device_discovery.added": "Device added successfully", - "device_discovery.error.add_failed": "Failed to add device", - "calibration.error.load_failed": "Failed to load calibration", - "calibration.error.css_load_failed": "Failed to load color strip source", - "calibration.error.test_toggle_failed": "Failed to toggle test edge", - "calibration.error.save_failed": "Failed to save calibration", - "calibration.error.led_count_mismatch": "Total LEDs must equal the device LED count", - "calibration.error.led_count_exceeded": "Calibrated LEDs exceed the total LED count", - "calibration.mode.simple": "Simple", - "calibration.mode.advanced": "Advanced", - "calibration.switch_to_advanced": "Switch to Advanced", - "calibration.advanced.title": "Advanced Calibration", - "calibration.advanced.switch_to_simple": "Switch to Simple", - "calibration.advanced.lines_title": "Lines", - "calibration.advanced.canvas_hint": "Drag monitors to reposition. Click edges to select lines. Scroll to zoom, drag empty space to pan.", - "calibration.advanced.reset_view": "Reset view", - "calibration.advanced.line_properties": "Line Properties", - "calibration.advanced.picture_source": "Source:", - "calibration.advanced.picture_source.hint": "The picture source (monitor) this line samples from", - "calibration.advanced.edge": "Edge:", - "calibration.advanced.edge.hint": "Which screen edge to sample pixels from", - "calibration.advanced.led_count": "LEDs:", - "calibration.advanced.led_count.hint": "Number of LEDs mapped to this line", - "calibration.advanced.span_start": "Span Start:", - "calibration.advanced.span_start.hint": "Where sampling begins along the edge (0 = start, 1 = end). Use to cover only part of an edge.", - "calibration.advanced.span_end": "Span End:", - "calibration.advanced.span_end.hint": "Where sampling ends along the edge (0 = start, 1 = end). Together with Span Start, defines the active portion.", - "calibration.advanced.border_width": "Depth (px):", - "calibration.advanced.border_width.hint": "How many pixels deep from the edge to sample. Larger values capture more of the screen interior.", - "calibration.advanced.reverse": "Reverse", - "calibration.advanced.no_lines_warning": "Add at least one line", - "dashboard.error.automation_toggle_failed": "Failed to toggle automation", - "dashboard.error.start_failed": "Failed to start processing", - "dashboard.error.stop_failed": "Failed to stop processing", - "dashboard.error.stop_all": "Failed to stop all targets", - "target.error.editor_open_failed": "Failed to open target editor", - "target.error.start_failed": "Failed to start target", - "target.error.stop_failed": "Failed to stop target", - "target.error.clone_failed": "Failed to clone target", - "target.error.delete_failed": "Failed to delete target", - "target.error.load_failed": "Failed to load target", - "targets.stop_all.button": "Stop All", - "targets.stop_all.none_running": "No targets are currently running", - "targets.stop_all.stopped": "Stopped {count} target(s)", - "targets.stop_all.error": "Failed to stop targets", - "audio_source.error.load": "Failed to load audio source", - "audio_template.error.clone_failed": "Failed to clone audio template", - "value_source.error.load": "Failed to load value source", - "color_strip.error.editor_open_failed": "Failed to open color strip editor", - "color_strip.error.clone_failed": "Failed to clone color strip source", - "color_strip.error.delete_failed": "Failed to delete color strip source", - "pattern.error.editor_open_failed": "Failed to open pattern template editor", - "pattern.error.clone_failed": "Failed to clone pattern template", - "pattern.error.delete_failed": "Failed to delete pattern template", - "pattern.error.capture_bg_failed": "Failed to capture background", - "pattern.error.save_failed": "Failed to save pattern template", - "stream.error.clone_picture_failed": "Failed to clone picture source", - "stream.error.clone_capture_failed": "Failed to clone capture template", - "stream.error.clone_pp_failed": "Failed to clone postprocessing template", - "theme.switched.dark": "Switched to dark theme", - "theme.switched.light": "Switched to light theme", - "theme.switched.system": "Switched to system theme", - "accent.color.updated": "Accent color updated", - "search.footer": "↑↓ navigate · Enter select · Esc close", - "sync_clock.group.title": "Sync Clocks", - "sync_clock.add": "Add Sync Clock", - "sync_clock.edit": "Edit Sync Clock", - "sync_clock.name": "Name:", - "sync_clock.name.placeholder": "Main Animation Clock", - "sync_clock.name.hint": "A descriptive name for this synchronization clock", - "sync_clock.speed": "Speed:", - "sync_clock.speed.hint": "Animation speed multiplier for all linked sources. 1.0 = normal, 2.0 = double speed, 0.5 = half speed.", - "sync_clock.description": "Description (optional):", - "sync_clock.description.placeholder": "Optional description", - "sync_clock.description.hint": "Optional notes about this clock's purpose", - "sync_clock.status.running": "Running", - "sync_clock.status.paused": "Paused", - "sync_clock.action.pause": "Pause", - "sync_clock.action.resume": "Resume", - "sync_clock.action.reset": "Reset", - "sync_clock.error.name_required": "Clock name is required", - "sync_clock.error.load": "Failed to load sync clock", - "sync_clock.created": "Sync clock created", - "sync_clock.updated": "Sync clock updated", - "sync_clock.deleted": "Sync clock deleted", - "sync_clock.paused": "Clock paused", - "sync_clock.resumed": "Clock resumed", - "sync_clock.reset_done": "Clock reset to zero", - "sync_clock.delete.confirm": "Delete this sync clock? Linked sources will lose synchronization and run at default speed.", - "sync_clock.elapsed": "Elapsed time", - "weather_source.group.title": "Weather Sources", - "weather_source.add": "Add Weather Source", - "weather_source.edit": "Edit Weather Source", - "weather_source.name": "Name:", - "weather_source.name.placeholder": "My Weather", - "weather_source.name.hint": "A descriptive name for this weather data source", - "weather_source.provider": "Provider:", - "weather_source.provider.hint": "Weather data provider. Open-Meteo is free and requires no API key.", - "weather_source.provider.open_meteo.desc": "Free, no API key required", - "weather_source.location": "Location:", - "weather_source.location.hint": "Your geographic coordinates. Use the auto-detect button or enter manually.", - "weather_source.latitude": "Lat:", - "weather_source.longitude": "Lon:", - "weather_source.use_my_location": "Use my location", - "weather_source.update_interval": "Update Interval:", - "weather_source.update_interval.hint": "How often to fetch weather data. Lower values give more responsive changes.", - "weather_source.description": "Description (optional):", - "weather_source.description.placeholder": "Optional description", - "weather_source.test": "Test", - "weather_source.error.name_required": "Weather source name is required", - "weather_source.error.load": "Failed to load weather source", - "weather_source.created": "Weather source created", - "weather_source.updated": "Weather source updated", - "weather_source.deleted": "Weather source deleted", - "weather_source.delete.confirm": "Delete this weather source? Linked color strip sources will lose weather data.", - "weather_source.geo.success": "Location detected", - "weather_source.geo.error": "Geolocation failed", - "weather_source.geo.not_supported": "Geolocation is not supported by your browser", - "streams.group.weather": "Weather", - "streams.group.home_assistant": "Home Assistant", - "ha_source.group.title": "Home Assistant Sources", - "ha_source.add": "Add Home Assistant Source", - "ha_source.edit": "Edit Home Assistant Source", - "ha_source.name": "Name:", - "ha_source.name.placeholder": "My Home Assistant", - "ha_source.name.hint": "A descriptive name for this Home Assistant connection", - "ha_source.host": "Host:", - "ha_source.host.hint": "Home Assistant host and port, e.g. 192.168.1.100:8123", - "ha_source.token": "Access Token:", - "ha_source.token.hint": "Long-Lived Access Token from HA (Profile > Security > Long-Lived Access Tokens)", - "ha_source.token.edit_hint": "Leave blank to keep the current token", - "ha_source.use_ssl": "Use SSL (wss://)", - "ha_source.entity_filters": "Entity Filters (optional):", - "ha_source.entity_filters.hint": "Comma-separated glob patterns, e.g. sensor.*, binary_sensor.front_door. Leave empty for all.", - "ha_source.description": "Description (optional):", - "ha_source.test": "Test Connection", - "ha_source.test.success": "Connected", - "ha_source.test.failed": "Connection failed", - "ha_source.connected": "Connected", - "ha_source.disconnected": "Disconnected", - "ha_source.error.name_required": "Name is required", - "ha_source.error.host_required": "Host is required", - "ha_source.error.token_required": "Access token is required", - "ha_source.error.load": "Failed to load Home Assistant source", - "ha_source.created": "Home Assistant source created", - "ha_source.updated": "Home Assistant source updated", - "ha_source.deleted": "Home Assistant source deleted", - "ha_source.delete.confirm": "Delete this Home Assistant connection?", - "section.empty.ha_sources": "No Home Assistant sources yet. Click + to add one.", - "streams.group.mqtt": "MQTT", - "mqtt_source.group.title": "MQTT Sources", - "mqtt_source.add": "Add MQTT Source", - "mqtt_source.edit": "Edit MQTT Source", - "mqtt_source.name": "Name:", - "mqtt_source.name.placeholder": "My MQTT Broker", - "mqtt_source.name.hint": "A descriptive name for this MQTT broker connection", - "mqtt_source.broker_host": "Broker Host:", - "mqtt_source.broker_host.hint": "MQTT broker hostname or IP address, e.g. 192.168.1.100", - "mqtt_source.broker_port": "Port:", - "mqtt_source.username": "Username (optional):", - "mqtt_source.password": "Password (optional):", - "mqtt_source.password.edit_hint": "Leave blank to keep the current password", - "mqtt_source.client_id": "Client ID:", - "mqtt_source.client_id.hint": "Unique MQTT client identifier. Change if running multiple instances.", - "mqtt_source.base_topic": "Base Topic:", - "mqtt_source.base_topic.hint": "Prefix for status and state topics, e.g. ledgrab/status", - "mqtt_source.description": "Description (optional):", - "mqtt_source.test": "Test Connection", - "mqtt_source.test.success": "Connected to broker", - "mqtt_source.test.failed": "Connection failed", - "mqtt_source.connected": "Connected", - "mqtt_source.disconnected": "Disconnected", - "mqtt_source.error.name_required": "Name is required", - "mqtt_source.error.host_required": "Broker host is required", - "mqtt_source.error.load": "Failed to load MQTT source", - "mqtt_source.created": "MQTT source created", - "mqtt_source.updated": "MQTT source updated", - "mqtt_source.deleted": "MQTT source deleted", - "mqtt_source.delete.confirm": "Delete this MQTT broker connection?", - "section.empty.mqtt_sources": "No MQTT sources yet. Click + to add one.", - "ha_light.section.title": "Home Assistant", - "ha_light.section.targets": "Light Targets", - "ha_light.add": "Add HA Light Target", - "ha_light.edit": "Edit HA Light Target", - "ha_light.name": "Name:", - "ha_light.name.placeholder": "Living Room Lights", - "ha_light.ha_source": "HA Connection:", - "ha_light.css_source": "Color Strip Source:", - "ha_light.color_source": "Color Source:", - "ha_light.color_source.hint": "Pick a Color Strip Source (per-light LED ranges) or a Color Value Source (one colour broadcast to every light).", - "ha_light.color_source.css": "Color strip", - "ha_light.color_source.color_vs": "Color value source", - "ha_light.mappings.color_vs_hint": "All listed lights will receive the same colour from the selected Color Value Source.", - "ha_light.update_rate": "Update Rate:", - "ha_light.update_rate.hint": "How often to send color updates to HA lights (0.5-5.0 Hz). Lower values are safer for HA performance.", - "ha_light.transition": "Transition:", - "ha_light.transition.hint": "Smooth fade duration between colors (HA transition parameter).", - "ha_light.color_tolerance": "Color Tolerance:", - "ha_light.color_tolerance.hint": "Skip sending color updates when the RGB delta is below this threshold. Reduces HA traffic for near-static scenes.", - "ha_light.min_brightness_threshold": "Min Brightness Threshold:", - "ha_light.min_brightness_threshold.hint": "Effective output brightness below this value turns lights off completely (0 = disabled).", - "ha_light.mappings": "Light Mappings:", - "ha_light.mappings.hint": "Map LED ranges to HA light entities. Each mapping averages the LED segment to a single color.", - "ha_light.mappings.add": "Add Mapping", - "ha_light.mapping.entity_id": "Entity ID:", - "ha_light.mapping.led_start": "LED Start:", - "ha_light.mapping.led_end": "LED End (-1=last):", - "ha_light.mapping.brightness": "Brightness Scale:", - "ha_light.description": "Description (optional):", - "ha_light.error.name_required": "Name is required", - "ha_light.error.ha_source_required": "HA connection is required", - "ha_light.error.color_source_required": "Color value source is required when broadcasting a single colour", - "ha_light.created": "HA light target created", - "ha_light.updated": "HA light target updated", - "ha_light.mapping.select_entity": "Select a light entity...", - "ha_light.mapping.unassigned": "— No entity selected", - "ha_light.mapping.search_entity": "Search light entities...", - "ha_light.stop_action": "On Stop:", - "ha_light.stop_action.hint": "What to do with the mapped lights when this target stops streaming.", - "ha_light.stop_action.none": "None", - "ha_light.stop_action.none.desc": "Leave lights as-is", - "ha_light.stop_action.turn_off": "Turn Off", - "ha_light.stop_action.turn_off.desc": "Switch all mapped lights off", - "ha_light.stop_action.restore": "Restore", - "ha_light.stop_action.restore.desc": "Revert to state captured at start", - "ha_light.button.turn_off": "Turn Off Lights", - "ha_light.turn_off.success": "Lights turned off", - "ha_light.turn_off.failed": "Failed to turn off lights", - "confirm.turn_off_ha_light": "Turn off all mapped lights?", - "ha_light.connection_tooltip": "HA Connection", - "ha_light.connection_metric": "Home Assistant connection", - "ha_light.lights.one": "{count} light", - "ha_light.lights.other": "{count} lights", - "section.empty.ha_light_targets": "No HA light targets yet. Click + to add one.", - "z2m_light.section.title": "Zigbee2MQTT", - "z2m_light.section.targets": "Z2M Light Targets", - "z2m_light.add": "Add Z2M Light Target", - "z2m_light.edit": "Edit Z2M Light Target", - "z2m_light.name": "Name:", - "z2m_light.name.placeholder": "Living Room Bulbs", - "z2m_light.description": "Description (optional):", - "z2m_light.mqtt_source": "MQTT Broker:", - "z2m_light.mqtt_source.hint": "Pick the MQTT source (broker) that Zigbee2MQTT is connected to. Manage brokers under MQTT Sources.", - "z2m_light.mqtt_source.none": "— Pick an MQTT broker", - "z2m_light.base_topic": "Z2M Base Topic:", - "z2m_light.base_topic.hint": "Override only if your Zigbee2MQTT instance uses a non-default mqtt.base_topic.", - "z2m_light.color_source": "Color Source:", - "z2m_light.color_source.hint": "Pick a Color Strip Source (per-bulb LED ranges) or a Color Value Source (one colour broadcast to every bulb).", - "z2m_light.color_source.css": "Color strip", - "z2m_light.color_source.color_vs": "Color value source", - "z2m_light.update_rate": "Update Rate:", - "z2m_light.update_rate.hint": "How often to publish colour updates to Z2M (0.5–10 Hz). Zigbee mesh tolerates ~5–10 Hz per bulb; higher values risk drops.", - "z2m_light.transition": "Transition:", - "z2m_light.transition.hint": "Smooth fade duration between colours, in seconds (Z2M transition parameter).", - "z2m_light.brightness": "Brightness:", - "z2m_light.color_tolerance": "Color Tolerance:", - "z2m_light.color_tolerance.hint": "Skip publishes when the RGB delta is below this threshold. Reduces Zigbee mesh traffic for near-static scenes.", - "z2m_light.min_brightness_threshold": "Min Brightness Threshold:", - "z2m_light.min_brightness_threshold.hint": "Effective output brightness below this value turns bulbs off completely (0 = disabled).", - "z2m_light.mappings": "Bulb Mappings:", - "z2m_light.mappings.hint": "Map LED ranges to Z2M friendly names. Each mapping averages the LED segment to a single colour.", - "z2m_light.mappings.color_vs_hint": "All listed bulbs will receive the same colour from the selected Color Value Source.", - "z2m_light.mappings.add": "Add Mapping", - "z2m_light.mapping.friendly_name": "Friendly Name:", - "z2m_light.mapping.led_start": "LED Start:", - "z2m_light.mapping.led_end": "LED End (-1=last):", - "z2m_light.mapping.brightness": "Brightness Scale:", - "z2m_light.mapping.unassigned": "— No bulb selected", - "z2m_light.stop_action": "On Stop:", - "z2m_light.stop_action.hint": "What to do with the mapped bulbs when this target stops streaming.", - "z2m_light.stop_action.none": "None", - "z2m_light.stop_action.none.desc": "Leave bulbs as-is", - "z2m_light.stop_action.turn_off": "Turn Off", - "z2m_light.stop_action.turn_off.desc": "Switch all mapped bulbs off", - "z2m_light.connection_metric": "Zigbee2MQTT broker connection", - "z2m_light.error.name_required": "Name is required", - "z2m_light.error.mqtt_source_required": "MQTT broker is required", - "z2m_light.error.color_source_required": "Color value source is required when broadcasting a single colour", - "z2m_light.error.mapping_required": "At least one bulb mapping is required", - "z2m_light.created": "Z2M light target created", - "z2m_light.updated": "Z2M light target updated", - "z2m_light.button.turn_off": "Turn Off Bulbs", - "z2m_light.turn_off.success": "Bulbs turned off", - "z2m_light.turn_off.failed": "Failed to turn off bulbs", - "confirm.turn_off_z2m_light": "Turn off all mapped bulbs?", - "z2m_light.bulbs.one": "{count} bulb", - "z2m_light.bulbs.other": "{count} bulbs", - "section.empty.z2m_light_targets": "No Z2M light targets yet. Click + to add one.", - "automations.rule.home_assistant": "Home Assistant", - "automations.rule.home_assistant.desc": "HA entity state", - "automations.rule.home_assistant.ha_source": "HA Source:", - "automations.rule.home_assistant.entity_id": "Entity ID:", - "automations.rule.home_assistant.state": "State:", - "automations.rule.home_assistant.match_mode": "Match Mode:", - "automations.rule.home_assistant.hint": "Activate when a Home Assistant entity matches the specified state", - "automations.rule.ha.match_mode.exact.desc": "State must match exactly", - "automations.rule.ha.match_mode.contains.desc": "State must contain the text", - "automations.rule.ha.match_mode.regex.desc": "State must match the regex pattern", - "color_strip.clock": "Sync Clock:", - "color_strip.clock.hint": "Link to a sync clock to synchronize animation timing across sources. Speed is controlled on the clock.", - "graph.title": "Graph", - "graph.fit_all": "Fit all nodes", - "graph.zoom_in": "Zoom in", - "graph.zoom_out": "Zoom out", - "graph.search": "Search nodes", - "graph.search_placeholder": "Search entities...", - "graph.legend": "Legend", - "graph.minimap": "Minimap", - "graph.relayout": "Re-layout", - "graph.empty": "No entities yet", - "graph.empty.hint": "Create devices, sources, and targets to see them here.", - "graph.disconnect": "Disconnect", - "graph.connection_updated": "Connection updated", - "graph.connection_failed": "Failed to update connection", - "graph.connection_removed": "Connection removed", - "graph.disconnect_failed": "Failed to disconnect", - "graph.relayout_confirm": "Reset all manual node positions and re-layout the graph?", - "graph.fullscreen": "Toggle fullscreen", - "graph.add_entity": "Add entity", - "graph.color_picker": "Node color", - "graph.filter": "Filter nodes", - "graph.filter_placeholder": "Filter: name, type:x, tag:x", - "graph.filter_clear": "Clear filter", - "graph.filter_running": "Running", - "graph.filter_stopped": "Stopped", - "graph.filter_types": "Types", - "graph.filter_group.capture": "Capture", - "graph.filter_group.strip": "Color Strip", - "graph.filter_group.audio": "Audio", - "graph.filter_group.targets": "Targets", - "graph.filter_group.other": "Other", - "graph.bulk_delete_confirm": "Delete {count} selected entities?", - "graph.nothing_to_undo": "Nothing to undo", - "graph.nothing_to_redo": "Nothing to redo", - "graph.help_title": "Keyboard Shortcuts", - "graph.help.search": "Search", - "graph.help.filter": "Filter", - "graph.help.add": "Add entity", - "graph.help.shortcuts": "Shortcuts", - "graph.help.delete": "Delete / Detach", - "graph.help.select_all": "Select all", - "graph.help.undo": "Undo", - "graph.help.redo": "Redo", - "graph.help.fullscreen": "Fullscreen", - "graph.help.deselect": "Deselect", - "graph.help.navigate": "Navigate nodes", - "graph.help.click": "Click", - "graph.help.click_desc": "Select node", - "graph.help.dblclick": "Double-click", - "graph.help.dblclick_desc": "Zoom to node", - "graph.help.shift_click": "Shift+Click", - "graph.help.shift_click_desc": "Multi-select", - "graph.help.shift_drag": "Shift+Drag", - "graph.help.shift_drag_desc": "Rubber-band select", - "graph.help.drag_node": "Drag node", - "graph.help.drag_node_desc": "Reposition", - "graph.help.drag_port": "Drag port", - "graph.help.drag_port_desc": "Connect entities", - "graph.help.right_click": "Right-click edge", - "graph.help.right_click_desc": "Detach connection", - "graph.tooltip.fps": "FPS", - "graph.tooltip.errors": "Errors", - "graph.tooltip.uptime": "Uptime", - "graph.undone": "Undone", - "graph.redone": "Redone", - "graph.action.connect": "Connect", - "graph.action.disconnect": "Disconnect", - "graph.action.move": "Move node", - "graph.action.rewire": "Re-wire slot", - "graph.choose_connection": "Choose connection", - "graph.rewire": "Re-wire…", - "graph.rewire_choose_source": "Choose a new source", - "graph.issues": "Issues", - "graph.issues_none": "No issues found", - "graph.issue.broken_ref": "Broken reference: {field}", - "graph.issue.cycle": "Part of a dependency cycle", - "graph.replace_connection_confirm": "Replace the existing connection?", - "graph.no_compatible_connection": "No compatible connection between these entities", - "graph.create_and_connect": "Create & connect…", - "graph.export": "Export graph (JSON)", - "graph.export_done": "Graph exported", - "graph.export_failed": "Failed to export graph", - "graph.duplicate": "Duplicate selection", - "graph.duplicate_none": "Select one or more nodes to duplicate", - "graph.duplicate_none_eligible": "Nothing duplicable in the selection (only value & colour-strip sources)", - "graph.duplicate_done": "Duplicated {count} source(s)", - "graph.duplicate_done_warn": "Duplicated {count} source(s) — some references could not be remapped", - "graph.duplicate_failed": "Failed to duplicate selection", - "graph.delete_with_dependents_confirm": "This entity is used by {count} other(s): {names}. Delete it and break those connections?", - "automation.enabled": "Automation enabled", - "automation.disabled": "Automation disabled", - "scene_preset.activated": "Preset activated", - "scene_preset.used_by": "Used by %d automation(s)", - "settings.api_keys.label": "API Keys", - "settings.api_keys.hint": "API keys are defined in the server config file (config.yaml). Edit the file and restart the server to apply changes.", - "settings.api_keys.empty": "No API keys configured", - "settings.api_keys.load_error": "Failed to load API keys", - "settings.partial.label": "Partial Export / Import", - "settings.partial.hint": "Export or import a single entity type. Import replaces or merges existing data and restarts the server.", - "settings.partial.store.devices": "Devices", - "settings.partial.store.output_targets": "LED Targets", - "settings.partial.store.color_strip_sources": "Color Strips", - "settings.partial.store.picture_sources": "Picture Sources", - "settings.partial.store.audio_sources": "Audio Sources", - "settings.partial.store.audio_templates": "Audio Templates", - "settings.partial.store.capture_templates": "Capture Templates", - "settings.partial.store.postprocessing_templates": "Post-processing Templates", - "settings.partial.store.color_strip_processing_templates": "CSS Processing Templates", - "settings.partial.store.pattern_templates": "Pattern Templates", - "settings.partial.store.value_sources": "Value Sources", - "settings.partial.store.sync_clocks": "Sync Clocks", - "settings.partial.store.automations": "Automations", - "settings.partial.store.scene_presets": "Scene Presets", - "settings.partial.export_button": "Export", - "settings.partial.import_button": "Import from File", - "settings.partial.merge_label": "Merge (add/overwrite, keep existing)", - "settings.partial.export_success": "Exported successfully", - "settings.partial.export_error": "Export failed", - "settings.partial.import_success": "Imported successfully", - "settings.partial.import_error": "Import failed", - "settings.partial.import_confirm_replace": "This will REPLACE all {store} data and restart the server. Continue?", - "settings.partial.import_confirm_merge": "This will MERGE into existing {store} data and restart the server. Continue?", - "section.empty.devices": "No devices yet. Click + to add one.", - "section.empty.targets": "No LED targets yet. Click + to add one.", - "section.empty.kc_targets": "No key color targets yet. Click + to add one.", - "section.empty.pattern_templates": "No pattern templates yet. Click + to add one.", - "section.empty.picture_sources": "No sources yet. Click + to add one.", - "section.empty.capture_templates": "No capture templates yet. Click + to add one.", - "section.empty.pp_templates": "No post-processing templates yet. Click + to add one.", - "section.empty.audio_sources": "No audio sources yet. Click + to add one.", - "section.empty.audio_templates": "No audio templates yet. Click + to add one.", - "section.empty.color_strips": "No color strips yet. Click + to add one.", - "section.empty.value_sources": "No value sources yet. Click + to add one.", - "section.empty.sync_clocks": "No sync clocks yet. Click + to add one.", - "section.empty.weather_sources": "No weather sources yet. Click + to add one.", - "section.empty.cspt": "No CSS processing templates yet. Click + to add one.", - "section.empty.automations": "No automations yet. Click + to add one.", - "section.empty.scenes": "No scene presets yet. Click + to add one.", - "section.empty.playlists": "No playlists yet. Click + to add one.", - "bulk.select": "Select", - "bulk.cancel": "Cancel", - "bulk.selected_count.one": "{count} selected", - "bulk.selected_count.other": "{count} selected", - "bulk.select_all": "Select all", - "bulk.deselect_all": "Deselect all", - "bulk.delete": "Delete", - "bulk.start": "Start", - "bulk.stop": "Stop", - "bulk.enable": "Enable", - "bulk.disable": "Disable", - "bulk.confirm_delete.one": "Delete {count} item?", - "bulk.confirm_delete.other": "Delete {count} items?", - "appearance.style.label": "Style Presets", - "appearance.style.hint": "Choose a visual theme — font pairing and color palette applied together.", - "appearance.preset.default": "Default", - "appearance.preset.midnight": "Midnight", - "appearance.preset.ember": "Ember", - "appearance.preset.arctic": "Arctic", - "appearance.preset.terminal": "Terminal", - "appearance.preset.neon": "Neon", - "appearance.preset.sakura": "Sakura", - "appearance.preset.ocean": "Ocean", - "appearance.preset.copper": "Copper", - "appearance.preset.vapor": "Vapor", - "appearance.preset.monolith": "Monolith", - "appearance.preset.applied": "Style preset applied", - "appearance.bg.label": "Background Effects", - "appearance.bg.hint": "Add an ambient background layer behind the interface.", - "appearance.bg.none": "None", - "appearance.bg.noise": "Noise Field", - "appearance.bg.aurora": "Aurora", - "appearance.bg.plasma": "Plasma", - "appearance.bg.rain": "Digital Rain", - "appearance.bg.stars": "Starfield", - "appearance.bg.warp": "Warp Tunnel", - "appearance.bg.grid": "Dot Grid", - "appearance.bg.mesh": "Gradient Mesh", - "appearance.bg.scanlines": "Scanlines", - "appearance.bg.applied": "Background effect applied", - "settings.tab.notifications": "Notifications", - "settings.tab.updates": "Updates", - "settings.tab.about": "About", - "settings.notifications.intro_label": "Device events", - "settings.notifications.intro_hint": "Pick how each device event reaches you. \"Snack\" shows an in-app toast, \"OS\" shows a system notification (browser must be granted permission), \"Both\" shows both, \"None\" silences the event entirely.", - "settings.notifications.row.online": "Device came online", - "settings.notifications.row.offline": "Device went offline", - "settings.notifications.row.discovered": "New device found", - "settings.notifications.row.lost": "Discovered device lost", - "settings.notifications.background.label": "Background discovery", - "settings.notifications.background.hint": "Continuously scan the LAN (mDNS) and serial bus for new LED devices. Disable to silence \"device discovered/lost\" events at the source. Restart the server to apply.", - "settings.notifications.background.toggle": "Enable background discovery", - "settings.notifications.permission.label": "OS notification permission", - "settings.notifications.permission.grant": "Grant permission", - "settings.notifications.permission.granted": "OS notifications enabled", - "settings.notifications.permission.denied": "OS notifications blocked — change in browser settings", - "settings.notifications.permission.state.granted": "Granted — OS toasts will appear", - "settings.notifications.permission.state.denied": "Denied — change in browser settings", - "settings.notifications.permission.state.default": "Not yet requested", - "settings.notifications.permission.hint": "The browser controls OS notification permission per site. Once denied, LedGrab can no longer ask again — you need to clear it in the browser. Click the site icon (lock) in the address bar → Site settings → Notifications → Allow, then reload.", - "settings.notifications.test_button": "Send a test notification", - "settings.notifications.saved": "Notification preferences saved", - "settings.notifications.save_error": "Failed to save notification preferences", - "settings.notifications.channel.none.label": "Off", - "settings.notifications.channel.none.desc": "Silence this event", - "settings.notifications.channel.snack.label": "Snack", - "settings.notifications.channel.snack.desc": "In-app toast at the bottom of the page", - "settings.notifications.channel.os.label": "OS", - "settings.notifications.channel.os.desc": "System notification (works while the browser is in the background)", - "settings.notifications.channel.both.label": "Both", - "settings.notifications.channel.both.desc": "In-app toast and system notification", - "settings.notifications.permission.pill.granted": "GRANTED", - "settings.notifications.permission.pill.denied": "BLOCKED", - "settings.rail.group.workspace": "Workspace", - "settings.rail.group.system": "System", - "settings.save_bar.unsaved": "Unsaved changes in", - "settings.save_bar.revert": "Revert", - "settings.save_bar.save": "Save", - "settings.section.api_keys": "Identity & API", - "settings.section.server": "Server", - "settings.section.lifecycle": "Lifecycle", - "settings.section.destructive": "DESTRUCTIVE", - "settings.section.manual": "Manual", - "settings.section.notif_channels": "Channels", - "settings.section.notif_discovery": "Discovery", - "settings.section.notif_permission": "OS Permission", - "settings.api_keys.read_only": "Read-only", - "settings.api_keys.meta.one": "key", - "settings.api_keys.meta.many": "keys", - "settings.logs.sub": "Live tail of server log output, filterable by level. Opens in a full-screen overlay.", - "settings.restart.sub": "Bounce the LedGrab process. Active capture and connected devices will pause for ~3 seconds.", - "settings.restart.button": "Restart", - "settings.notif_matrix.col.event": "Event", - "settings.notif_matrix.event_count": "4 EVENTS", - "settings.auto_backup.pill.running": "RUNNING", - "update.pill.available": "UPDATE AVAILABLE", - "update.pill.error": "ERROR", - "update.pill.updated": "UP TO DATE", - "notifications.unknown_device": "Unknown device", - "notifications.device_online.title": "Device online", - "notifications.device_online.body": "{device} is back online", - "notifications.device_offline.title": "Device offline", - "notifications.device_offline.body": "{device} stopped responding", - "notifications.device_discovered.title": "New device found", - "notifications.device_discovered.body": "{device} appeared on your network", - "notifications.device_lost.title": "Device lost", - "notifications.device_lost.body": "{device} disappeared", - "notifications.bulk.title": "Multiple devices changed", - "notifications.bulk.body": "{count} device events fired at once", - "notifications.test.title": "LedGrab test notification", - "notifications.test.body": "Notifications are wired up correctly.", - "update.status_label": "Update Status", - "update.current_version": "Current version:", - "update.badge_tooltip": "New version available — click for details", - "update.available": "Version {version} is available", - "update.up_to_date": "You are running the latest version", - "update.prerelease": "pre-release", - "update.view_release": "View Release", - "update.dismiss": "Dismiss", - "update.check_now": "Check for Updates", - "update.check_error": "Update check failed", - "update.last_check": "Last check", - "update.never": "never", - "update.release_notes": "Release Notes", - "update.view_release_notes": "View Release Notes", - "update.release_notes_hint": "What's new in the available version — read the changelog before applying.", - "update.release_notes_open": "Open", - "update.assets.title": "Downloads", - "update.assets.desc.windows_installer": "Windows installer — Start Menu shortcut, optional autostart, uninstaller", - "update.assets.desc.windows_portable": "Windows portable — unzip anywhere, run LedGrab.bat", - "update.assets.desc.windows_msi": "Windows MSI installer", - "update.assets.desc.windows_exe": "Windows executable", - "update.assets.desc.linux_tarball": "Linux archive — extract, run ./run.sh", - "update.assets.desc.linux_appimage": "Linux portable — single executable", - "update.assets.desc.linux_deb": "Debian / Ubuntu package", - "update.assets.desc.linux_rpm": "Fedora / RHEL package", - "update.assets.desc.linux_sandbox": "Sandboxed Linux package", - "update.assets.desc.macos_dmg": "macOS disk image — drag to Applications", - "update.assets.desc.macos_installer": "macOS installer package", - "update.assets.desc.android": "Android — sideload on Android 7.0+", - "update.assets.desc.android_bundle": "Android App Bundle (Play Store)", - "update.assets.desc.ios": "iOS application", - "update.assets.desc.zip_archive": "ZIP archive", - "update.assets.desc.tarball": "Tar archive", - "update.assets.desc.archive": "Compressed archive", - "update.auto_check_label": "Auto-Check Settings", - "update.auto_check_hint": "Periodically check for new releases in the background.", - "update.enable": "Enable auto-check", - "update.interval_label": "Check interval", - "update.channel_label": "Channel", - "update.channel.stable": "Stable", - "update.channel.stable_desc": "Stable releases only", - "update.channel.prerelease": "Pre-release", - "update.channel.prerelease_desc": "Include alpha, beta, and RC builds", - "update.save_settings": "Save Settings", - "update.settings_saved": "Update settings saved", - "update.settings_save_error": "Failed to save update settings", - "update.apply_now": "Update Now", - "update.apply_confirm": "Download and install version {version}? The server will restart automatically.", - "update.apply_error": "Update failed", - "update.applying": "Applying update…", - "update.downloading": "Downloading…", - "update.install_type_label": "Install type:", - "update.install_type.installer": "Windows installer", - "update.install_type.portable": "Portable", - "update.install_type.docker": "Docker", - "update.install_type.dev": "Development", - "color_strip.notification.search_apps": "Search notification apps…", - "asset.group.title": "Assets", - "asset.upload": "Upload Asset", - "asset.edit": "Edit Asset", - "asset.name": "Name:", - "asset.name.hint": "Display name for this asset.", - "asset.description": "Description:", - "asset.description.hint": "Optional description for this asset.", - "asset.file": "File:", - "asset.file.hint": "Select a file to upload (sound, image, video, or other).", - "asset.drop_or_browse": "Drop file here or click to browse", - "asset.uploaded": "Asset uploaded", - "asset.updated": "Asset updated", - "asset.deleted": "Asset deleted", - "asset.confirm_delete": "Delete this asset?", - "asset.error.name_required": "Name is required", - "asset.error.no_file": "Please select a file to upload", - "asset.error.delete_failed": "Failed to delete asset", - "asset.error.play_failed": "Failed to play sound", - "asset.error.download_failed": "Failed to download asset", - "asset.play": "Play", - "asset.download": "Download", - "asset.prebuilt": "Prebuilt", - "asset.prebuilt_restored": "{count} prebuilt asset(s) restored", - "asset.prebuilt_none_to_restore": "All prebuilt assets are already available", - "asset.restore_prebuilt": "Restore Prebuilt Sounds", - "asset.type.sound": "Sound", - "asset.type.image": "Image", - "asset.type.video": "Video", - "asset.type.other": "Other", - "streams.group.assets": "Assets", - "section.empty.assets": "No assets yet. Click + to upload one.", - "donation.message": "LedGrab is free & open-source. If it's useful to you, consider supporting development.", - "donation.support": "Support the project", - "donation.view_source": "View source code", - "donation.about": "About", - "donation.later": "Remind me later", - "donation.dismiss": "Don't show again", - "donation.about_title": "About LedGrab", - "donation.about_opensource": "LedGrab is open-source software, free to use and modify.", - "donation.about_donate": "Support development", - "donation.about_license": "MIT License", - "donation.about_author": "Created by", - "streams.group.game": "Game Integration", - "tree.group.game": "Game", - "game_integration.section_title": "Game Integrations", - "section.empty.game_integrations": "No game integrations yet. Click + to create one.", - "game_integration.add": "Add Game Integration", - "game_integration.edit": "Edit Game Integration", - "game_integration.created": "Game integration created", - "game_integration.updated": "Game integration updated", - "game_integration.deleted": "Game integration deleted", - "game_integration.confirm_delete": "Delete this game integration?", - "game_integration.error.name_required": "Name is required", - "game_integration.error.save_failed": "Failed to save game integration", - "game_integration.error.delete_failed": "Failed to delete game integration", - "game_integration.error.save_first": "Save the integration first to test the connection", - "game_integration.name": "Name:", - "game_integration.name.hint": "A descriptive name for this game integration", - "game_integration.description": "Description:", - "game_integration.description.hint": "Optional description of what this integration does", - "game_integration.enabled": "Enabled", - "game_integration.adapter_type": "Game / Adapter:", - "game_integration.adapter_type.hint": "Select the game or adapter type for this integration", - "game_integration.adapter_config": "Adapter Configuration", - "game_integration.no_config": "No configuration required for this adapter.", - "game_integration.setup_instructions": "Setup Instructions", - "game_integration.setup_instructions.hint": "Follow these steps to configure your game to send data to this integration", - "game_integration.event_mappings": "Event Mappings", - "game_integration.event_mappings.hint": "Map game events to LED effects. Each event type can trigger a different visual effect.", - "game_integration.mapping.add": "+ Add Mapping", - "game_integration.mapping.event_type": "Event", - "game_integration.mapping.effect_type": "Effect", - "game_integration.mapping.color": "Color", - "game_integration.mapping.duration": "Duration (ms)", - "game_integration.mapping.intensity": "Intensity", - "game_integration.mapping.priority": "Priority", - "game_integration.mapping.select_preset": "Load preset...", - "game_integration.preset.select": "Load preset...", - "game_integration.preset.fps_combat": "FPS Combat", - "game_integration.preset.moba_health": "MOBA Health", - "game_integration.adapter": "Adapter", - "game_integration.status": "Status", - "game_integration.status.active": "Active", - "game_integration.status.inactive": "Inactive", - "game_integration.mappings": "Mappings", - "game_integration.events.title": "Live Events", - "game_integration.events.waiting": "Waiting for events...", - "game_integration.events.monitor": "Event Monitor", - "game_integration.test.button": "Test Connection", - "game_integration.test.waiting": "Waiting for events from game...", - "game_integration.test.success": "Connection successful! Received events.", - "game_integration.test.error": "Connection error", - "game_integration.test.timeout": "No events received within timeout period.", - "game_integration.auto_setup": "Auto Setup", - "game_integration.auto_setup.success": "Configuration file written successfully", - "game_integration.auto_setup.failed": "Auto setup failed", - "game_integration.auto_setup.not_supported": "This adapter does not support auto setup", - "game_integration.auto_setup.game_not_found": "Game installation not found", - "game_integration.auto_setup.token_generated": "Auth token was automatically generated", - "game_integration.auto_setup.save_first": "Save the integration first before running auto setup", - "color_strip.type.game_event": "Game Event", - "color_strip.type.game_event.desc": "LED effects triggered by game events", - "color_strip.game_event.integration": "Game Integration:", - "color_strip.game_event.integration.hint": "Select the game integration that provides events for this source.", - "color_strip.game_event.idle_color": "Idle Color:", - "color_strip.game_event.idle_color.hint": "LED color when no game events are active.", - "color_strip.game_event.event_mappings": "Event Mappings:", - "color_strip.game_event.event_mappings.hint": "Override or add event-to-effect mappings for this source. These supplement the integration-level mappings.", - "color_strip.game_event.error.no_integration": "Please select a game integration.", - "color_strip.type.math_wave": "Math Wave", - "color_strip.type.math_wave.desc": "Mathematical wave generator with gradient color mapping", - "color_strip.math_wave.gradient": "Color Gradient:", - "color_strip.math_wave.gradient.hint": "The gradient used to color the wave output. Wave values are mapped to positions along this gradient.", - "color_strip.math_wave.speed": "Speed:", - "color_strip.math_wave.speed.hint": "Animation speed multiplier. Higher values make the wave move faster.", - "color_strip.math_wave.waves": "Wave Layers:", - "color_strip.math_wave.waves.hint": "Add multiple wave layers that are combined together. Each wave has its own waveform, frequency, amplitude, phase and offset.", - "color_strip.math_wave.add_wave": "+ Add Wave", - "color_strip.math_wave.waveform": "Waveform", - "color_strip.math_wave.waveform.sine": "Sine", - "color_strip.math_wave.waveform.triangle": "Triangle", - "color_strip.math_wave.waveform.sawtooth": "Sawtooth", - "color_strip.math_wave.waveform.square": "Square", - "color_strip.math_wave.frequency": "Frequency", - "color_strip.math_wave.amplitude": "Amplitude", - "color_strip.math_wave.phase": "Phase", - "color_strip.math_wave.offset": "Offset", - "color_strip.math_wave.error.no_waves": "Add at least one wave layer.", - "value_source.type.game_event": "Game Event", - "value_source.type.game_event.desc": "Game metrics (health, ammo, mana) as 0-1 values", - "value_source.game_event.integration": "Game Integration:", - "value_source.game_event.integration.hint": "Select the game integration that provides events for this value source.", - "value_source.game_event.event_type": "Event Type:", - "value_source.game_event.event_type.hint": "The continuous game event to track (health, mana, ammo, etc.).", - "value_source.game_event.min_game_value": "Min Game Value:", - "value_source.game_event.min_game_value.hint": "Raw game value that maps to output 0.0.", - "value_source.game_event.max_game_value": "Max Game Value:", - "value_source.game_event.max_game_value.hint": "Raw game value that maps to output 1.0.", - "value_source.game_event.smoothing": "Smoothing:", - "value_source.game_event.smoothing.hint": "EMA smoothing factor. 0 = instant, higher = smoother transitions.", - "value_source.game_event.default_value": "Default Value:", - "value_source.game_event.default_value.hint": "Output value when no events received within timeout.", - "value_source.game_event.timeout": "Timeout (s):", - "value_source.game_event.timeout.hint": "Seconds of silence before reverting to the default value.", - "audio_processing.title": "Audio Processing Templates", - "audio_processing.add": "Add Audio Processing Template", - "audio_processing.edit": "Edit Audio Processing Template", - "audio_processing.name": "Template Name:", - "audio_processing.name.hint": "A descriptive name for this audio processing template", - "audio_processing.name_placeholder": "My Audio Processing Template", - "audio_processing.description_label": "Description (optional):", - "audio_processing.description.hint": "Describe what this template does", - "audio_processing.description_placeholder": "Describe this template...", - "audio_processing.created": "Audio processing template created", - "audio_processing.updated": "Audio processing template updated", - "audio_processing.deleted": "Audio processing template deleted", - "audio_processing.delete.confirm": "Are you sure you want to delete this audio processing template?", - "audio_processing.error.required": "Please fill in all required fields", - "audio_processing.error.load": "Error loading audio processing template", - "audio_processing.error.delete": "Error deleting audio processing template", - "audio_processing.error.clone_failed": "Failed to clone audio processing template", - "audio_processing.error.save_failed": "Failed to save audio processing template", - "audio_processing.filter_count": "Filter count", - "audio_processing.filters_label": "filters", - "streams.group.audio_processing": "Audio Processing", - "section.empty.audio_processing_templates": "No audio processing templates yet. Click + to create one.", - "audio_source.group.capture": "Capture Sources", - "audio_source.group.processed": "Processed Sources", - "pairing.title": "Pair Device", - "pairing.instructions.default": "Press and hold the pairing button on your device, then click Start. Devices typically open a 30-second pairing window — be ready to act quickly.", - "pairing.start": "Start pairing", - "pairing.pairing": "Pairing…", - "pairing.cancel": "Cancel", - "pairing.retry": "Try again", - "pairing.close": "Close", - "pairing.success": "Paired successfully", - "pairing.not_ready": "Device didn't respond. Press the pairing button on your device, then try again.", - "pairing.failed": "Pairing failed: {detail}", - "pairing.failed_prefix": "Pairing failed:", - "streams.group.http": "HTTP", - "http_endpoint.group.title": "HTTP Endpoints", - "http_endpoint.add": "Add HTTP Endpoint", - "http_endpoint.edit": "Edit HTTP Endpoint", - "http_endpoint.section.request": "Request", - "http_endpoint.section.headers": "Headers", - "http_endpoint.name": "Name:", - "http_endpoint.name.placeholder": "Plex now-playing", - "http_endpoint.name.hint": "A descriptive name for this endpoint", - "http_endpoint.url": "URL:", - "http_endpoint.url.hint": "Full http(s) URL to poll. Local addresses are allowed.", - "http_endpoint.method": "Method:", - "http_endpoint.method.get.desc": "Fetch the response body.", - "http_endpoint.method.head.desc": "Status code only — no body. Great for liveness probes.", - "http_endpoint.auth_token": "Auth Token (optional):", - "http_endpoint.auth_token.hint": "Sent as 'Authorization: Bearer '. Add a custom Authorization header to override.", - "http_endpoint.auth_token.edit_hint": "Leave blank to keep the current token", - "http_endpoint.auth_token.reveal": "Show / hide token", - "http_endpoint.auth.set": "Auth", - "http_endpoint.timeout": "Timeout (s):", - "http_endpoint.timeout.hint": "Maximum seconds to wait for a single request.", - "http_endpoint.test": "Test request", - "http_endpoint.test.pending": "Testing…", - "http_endpoint.test.success": "OK", - "http_endpoint.test.failed": "Failed", - "http_endpoint.test.body.json": "JSON body", - "http_endpoint.test.body.text": "Response body", - "http_endpoint.headers": "Custom Headers:", - "http_endpoint.headers.hint": "Optional request headers (e.g. X-API-Key, Accept).", - "http_endpoint.headers.add": "Add header", - "http_endpoint.headers.empty": "No custom headers — defaults will be sent.", - "http_endpoint.headers.name_placeholder": "Header name", - "http_endpoint.headers.value_placeholder": "Header value", - "http_endpoint.headers.count": "{n} headers", - "http_endpoint.description": "Description (optional):", - "http_endpoint.created": "HTTP endpoint created", - "http_endpoint.updated": "HTTP endpoint updated", - "http_endpoint.deleted": "HTTP endpoint deleted", - "http_endpoint.delete.confirm": "Delete this HTTP endpoint? Value sources that reference it will need to be repointed.", - "http_endpoint.error.name_required": "Name is required", - "http_endpoint.error.url_required": "URL is required", - "http_endpoint.error.timeout_invalid": "Timeout must be a positive number", - "http_endpoint.error.load": "Failed to load HTTP endpoint", - "section.empty.http_endpoints": "No HTTP endpoints yet. Click + to add one.", - "device.icon.entity.http_endpoint": "HTTP endpoint", - "value_source.type.http": "HTTP Poll", - "value_source.type.http.desc": "Polls an HTTP endpoint at a fixed cadence and maps the extracted value to 0-1.", - "value_source.http.endpoint": "HTTP Endpoint:", - "value_source.http.endpoint.hint": "Pick a saved endpoint from the HTTP integrations tab.", - "value_source.http.json_path": "JSON Path:", - "value_source.http.json_path.hint": "Empty = use raw response body. Use dotted/indexed path, e.g. MediaContainer.Metadata[0].title", - "value_source.http.interval": "Interval (s):", - "value_source.http.interval.hint": "Polling cadence in seconds. Multiple value sources can share one endpoint at different intervals.", - "value_source.http.min_value": "Min Value:", - "value_source.http.min_value.hint": "Raw extracted value that maps to output 0.0 (for normalisation).", - "value_source.http.max_value": "Max Value:", - "value_source.http.max_value.hint": "Raw extracted value that maps to output 1.0 (for normalisation).", - "value_source.http.modulator.summary": "Modulator mapping (optional)", - "value_source.http.modulator.hint": "Only used when this source drives brightness or color. Automation rules read the raw extracted value and ignore these settings.", - "value_source.http.endpoint_required": "HTTP endpoint is required", - "value_source.http.interval_invalid": "Interval must be at least 1 second", - "value_source.type.template": "Jinja Template", - "value_source.type.template.desc": "Combine bound inputs with a sandboxed Jinja expression to compute a 0-1 value.", - "value_source.template.expression": "Expression:", - "value_source.template.expression.hint": "A sandboxed Jinja expression returning a number. Bound inputs are available by name; use raw[name] for the un-normalized value.", - "value_source.template.expression.placeholder": "clamp((temp - 18) / 10, 0, 1)", - "value_source.template.inputs": "Inputs:", - "value_source.template.inputs.hint": "Bind float value sources to variable names you reference in the expression.", - "value_source.template.inputs.empty": "No inputs yet. Click + to bind a value source.", - "value_source.template.add_input": "+ Add Input", - "value_source.template.input_name": "variable name", - "value_source.template.input_count": "inputs", - "value_source.template.input_count_one": "input", - "value_source.template.default_value": "Default Value:", - "value_source.template.default_value.hint": "Output used when the expression cannot be evaluated (e.g. an input is missing).", - "value_source.template.eval_interval": "Eval Interval (s):", - "value_source.template.eval_interval.hint": "How often to re-evaluate the expression. 0 = every poll (re-evaluate as fast as the inputs update).", - "value_source.template.valid": "Expression is valid", - "value_source.template.hints.title": "Expression help", - "value_source.template.hints.inputs_title": "Bound inputs", - "value_source.template.hints.no_inputs": "No inputs bound yet", - "value_source.template.hints.globals_title": "Globals", - "value_source.template.hints.globals": "min(a, b), max(a, b), abs(x), round(x), clamp(x, lo, hi)", - "value_source.template.hints.raw_title": "Raw values", - "value_source.template.hints.raw": "raw[name] gives the un-normalized value of an input that has one.", - "value_source.template.hints.examples_title": "Examples", - "value_source.template.hints.time": "Tip: for time-of-day logic, bind an Adaptive (Time) or Daylight source as an input.", - "value_source.template.error.invalid_expr": "Expression is invalid", - "value_source.template.error.cycle": "This expression would create a dependency cycle", - "value_source.template.error.missing_input": "Every input needs a variable name", - "value_source.template.error.invalid_name": "Invalid variable name", - "value_source.template.error.reserved_name": "Reserved name cannot be used as an input", - "value_source.template.error.duplicate_name": "Duplicate input name", - "value_source.template.error.unbound": "Expression references an unbound variable", - "automations.rule.http_poll": "HTTP Poll", - "automations.rule.http_poll.desc": "Activate when the latest extracted value from an HTTP value source matches.", - "automations.rule.http_poll.hint": "Compares the latest extracted value against your input. The value source decides what gets extracted (raw body or JSON path).", - "automations.rule.http_poll.value_source": "HTTP Value Source", - "automations.rule.http_poll.operator": "Operator", - "automations.rule.http_poll.value": "Value", - "automations.rule.http_poll.value.placeholder": "playing", - "automations.rule.http_poll.no_source": "(no source)", - "automations.rule.http_poll.raw_body": "Raw body", - "automations.rule.http_poll.operator.equals": "Equals", - "automations.rule.http_poll.operator.equals.desc": "Exact string match.", - "automations.rule.http_poll.operator.not_equals": "Not equals", - "automations.rule.http_poll.operator.not_equals.desc": "Activates when the value is anything other than this.", - "automations.rule.http_poll.operator.contains": "Contains", - "automations.rule.http_poll.operator.contains.desc": "Substring match.", - "automations.rule.http_poll.operator.regex": "Regex", - "automations.rule.http_poll.operator.regex.desc": "JavaScript-style regular expression.", - "automations.rule.http_poll.operator.gt": "Greater than", - "automations.rule.http_poll.operator.gt.desc": "Numeric comparison (>) — requires numeric output.", - "automations.rule.http_poll.operator.lt": "Less than", - "automations.rule.http_poll.operator.lt.desc": "Numeric comparison (<) — requires numeric output.", - "automations.rule.http_poll.operator.exists": "Exists", - "automations.rule.http_poll.operator.exists.desc": "Activates whenever a value is successfully extracted (ignores the value).", - "autocal.modal.title": "Auto-Calibrate Strip", - "autocal.trigger.label": "Auto-calibrate", - "autocal.trigger.hint": "Automatically detect LED positions by walking the strip", - "autocal.device.title": "Select Device", - "autocal.device.desc": "Choose the WLED/device that drives this LED strip. The strip will briefly light up during calibration.", - "autocal.device.label": "Device", - "autocal.error.no_device": "Please select a device to continue.", - "autocal.corner.title": "Start Corner", - "autocal.corner.desc": "Which corner is LED #0 (the very first LED of the strip)?", - "autocal.corner.led_index": "LED 0 position", - "autocal.direction.title": "Strip Direction — Step {step}", - "autocal.direction.desc": "Which direction does the strip run from the start corner?", - "autocal.corners.title": "Mark Corners — {remaining} remaining", - "autocal.corners.desc": "Sweep to the next corner then tap Mark. Corner: {corner}", - "autocal.corners.desc_complete": "All 4 corners marked! Review and continue.", - "autocal.corners.index_label": "LED index", - "autocal.preview.title": "Preview & Save", - "autocal.preview.desc": "Review the detected layout and save to the strip source.", - "autocal.preview.start": "Start corner", - "autocal.preview.top": "Top LEDs", - "autocal.preview.right": "Right LEDs", - "autocal.preview.bottom": "Bottom LEDs", - "autocal.preview.left": "Left LEDs", - "autocal.preview.total": "Total LEDs", - "autocal.position.top_left": "Top-left", - "autocal.position.top_right": "Top-right", - "autocal.position.bottom_left": "Bottom-left", - "autocal.position.bottom_right": "Bottom-right", - "autocal.btn.cancel": "Cancel", - "autocal.btn.next": "Next", - "autocal.btn.back": "Back", - "autocal.btn.step_back": "Step back", - "autocal.btn.step_fwd": "Step forward", - "autocal.btn.mark_corner": "Mark corner", - "autocal.btn.solve": "Solve", - "autocal.btn.save": "Save", - "autocal.error.session_start_failed": "Failed to start calibration session.", - "autocal.error.session_stop_failed": "Failed to stop calibration session.", - "autocal.error.position_failed": "Failed to move to LED position.", - "autocal.error.solve_failed": "Failed to solve calibration.", - "autocal.error.save_failed": "Failed to save calibration.", - "autocal.error.css_required": "Auto-calibration requires a Color Strip Source (not a device-only target).", - "autocal.saved": "Calibration saved successfully.", - "wizard.modal.title": "Setup Wizard", - "wizard.rerun": "Rerun Setup Wizard", - "wizard.skip": "Skip", - "wizard.start": "Get Started", - "wizard.step.welcome": "Welcome", - "wizard.step.device": "Device", - "wizard.step.display": "Screen", - "wizard.step.scaffold": "Setup", - "wizard.step.calibrate": "Calibrate", - "wizard.step.start": "Start", - "wizard.step.done": "Done", - "wizard.welcome.title": "Welcome to LED Grab", - "wizard.welcome.desc": "Let's get your LED strip up and running in just a few steps.", - "wizard.welcome.item1": "Connect your LED controller", - "wizard.welcome.item2": "Choose your screen to capture", - "wizard.welcome.item3": "Calibrate your strip layout", - "wizard.welcome.item4": "Start the ambient light output", - "wizard.device.title": "Find Your Device", - "wizard.device.desc": "Scan the network for compatible LED controllers, or add one manually.", - "wizard.device.scanning": "Scanning network…", - "wizard.device.discovered": "Discovered on network", - "wizard.device.none_found": "No devices found. Try adding one manually.", - "wizard.device.rescan": "Rescan", - "wizard.device.existing": "Existing devices", - "wizard.device.manual.title": "Add Manually", - "wizard.device.manual.name": "Device Name", - "wizard.device.manual.name_placeholder": "My LED Strip", - "wizard.device.manual.url": "Device URL", - "wizard.device.manual.led_count": "LED Count", - "wizard.device.manual.add": "Add Device", - "wizard.display.title": "Choose Your Screen", - "wizard.display.desc": "Select the monitor or display you want to capture for ambient lighting.", - "wizard.display.loading": "Loading displays…", - "wizard.display.no_displays": "No displays detected. Enter the display index manually.", - "wizard.display.manual_index": "Display Index", - "wizard.display.primary": "Primary", - "wizard.display.index_prefix": "Display", - "wizard.display.confirm": "Use This Screen", - "wizard.scaffold.title": "Building Setup", - "wizard.scaffold.desc": "Creating the capture chain: screen source → color strip → LED output.", - "wizard.scaffold.building": "Creating entities…", - "wizard.scaffold.done": "Setup complete! Ready to calibrate.", - "wizard.calibrate.title": "Calibrate Strip Layout", - "wizard.calibrate.desc": "Tell LedGrab where your LED strip starts and how it runs around the screen.", - "wizard.calibrate.skip": "Skip Calibration", - "wizard.start.title": "Starting Output", - "wizard.start.starting": "Starting LED output…", - "wizard.start.done": "LED output is running!", - "wizard.start.failed": "Failed to start output. You can start it manually from the Targets tab.", - "wizard.done.title": "All Done!", - "wizard.done.desc": "Your ambient LED setup is active. Enjoy the light!", - "wizard.done.device": "Device", - "wizard.done.display": "Screen", - "wizard.done.finish": "Finish", - "wizard.error.no_device": "Please select or add a device first.", - "wizard.error.device_create_failed": "Failed to create device.", - "wizard.error.device_name_required": "Device name is required.", - "wizard.error.device_url_required": "Device URL is required.", - "wizard.error.scaffold_failed": "Setup failed. Please try again.", - "wizard.error.start_failed": "Failed to start LED output." + "accent.color.updated": "Accent color updated", + "accent.custom": "Custom", + "accent.reset": "Reset", + "accent.title": "Accent color", + "activity_log.category.auth": "Auth", + "activity_log.category.capture": "Capture", + "activity_log.category.device": "Device", + "activity_log.category.entity": "Entity", + "activity_log.category.system": "System", + "activity_log.col.actor": "Actor", + "activity_log.col.category": "Category", + "activity_log.col.message": "Message", + "activity_log.col.severity": "Severity", + "activity_log.col.time": "Time", + "activity_log.detail.action": "Action", + "activity_log.detail.actor": "Actor", + "activity_log.detail.entity": "Entity", + "activity_log.detail.id": "ID", + "activity_log.detail.metadata": "Metadata", + "activity_log.detail.timestamp": "Timestamp", + "activity_log.detail.title": "Entry detail", + "activity_log.disabled": "Activity logging is disabled.", + "activity_log.empty": "No activity entries match the current filters.", + "activity_log.empty_no_filters": "No activity has been recorded yet.", + "activity_log.error": "Failed to load activity log.", + "activity_log.export": "Export", + "activity_log.export.csv": "Export CSV", + "activity_log.export.downloading": "Downloading…", + "activity_log.export.error": "Export failed.", + "activity_log.export.json": "Export JSON", + "activity_log.filter.actor": "Actor", + "activity_log.filter.actor.placeholder": "system, api-key-name…", + "activity_log.filter.category": "Category", + "activity_log.filter.clear": "Clear filters", + "activity_log.filter.entity_type": "Entity type", + "activity_log.filter.entity_type.placeholder": "output_target, device…", + "activity_log.filter.search": "Search messages…", + "activity_log.filter.severity": "Severity", + "activity_log.filter.since": "From", + "activity_log.filter.title": "Filters", + "activity_log.filter.until": "To", + "activity_log.live": "Live", + "activity_log.load_more": "Load more", + "activity_log.loading": "Loading activity log…", + "activity_log.n_entries": "{n} entries", + "activity_log.new_entries": "{n} new", + "activity_log.preset.auth": "Auth", + "activity_log.preset.devices": "Devices", + "activity_log.preset.errors": "Errors", + "activity_log.preset.today": "Today", + "activity_log.severity.error": "Error", + "activity_log.severity.info": "Info", + "activity_log.severity.warning": "Warning", + "activity_log.subtitle": "Audit log of LedGrab actions", + "activity_log.title": "Activity", + "activity_log.msg.auth.rejected": "Authentication failed: {reason} (client: {client})", + "activity_log.msg.auth.ws_connected": "WebSocket session established by '{actor}'", + "activity_log.msg.activity_log.cleared": "Activity log cleared by '{actor}'", + "activity_log.msg.calibration.started": "Calibration started", + "activity_log.msg.calibration.stopped": "Calibration stopped", + "activity_log.msg.calibration.cancelled": "Calibration cancelled", + "activity_log.msg.playlist.started": "Playlist '{name}' started", + "activity_log.msg.playlist.stopped": "Playlist '{name}' stopped", + "activity_log.msg.scene.activated": "Scene '{name}' activated", + "activity_log.msg.device.adb_connected": "ADB connected to {address}", + "activity_log.msg.device.adb_disconnected": "ADB disconnected from {address}", + "activity_log.msg.device.discovered": "Device discovered at {address}", + "activity_log.msg.device.lost": "Device lost at {address}", + "activity_log.msg.device.online": "Device '{name}' came online", + "activity_log.msg.device.offline": "Device '{name}' went offline", + "activity_log.msg.update.applied": "Update applied", + "activity_log.msg.update.dismissed": "Update dismissed", + "activity_log.msg.settings.changed": "Setting '{key}' changed", + "activity_log.msg.audit_log.disabled": "Activity logging disabled", + "activity_log.msg.automation.activated": "Automation '{name}' activated", + "activity_log.msg.automation.deactivated": "Automation '{name}' deactivated", + "activity_log.msg.server.shutting_down": "Server shutting down", + "activity_log.msg.server.restarting": "Server restart requested", + "activity_log.msg.server.shutdown_requested": "Server shutdown requested", + "activity_log.msg.backup.created": "Backup created", + "activity_log.msg.backup.restored": "Backup restored", + "activity_log.msg.backup.deleted": "Backup deleted", + "activity_log.msg.capture.started": "Capture started for '{name}'", + "activity_log.msg.capture.stopped": "Capture stopped for '{name}'", + "activity_log.msg.entity.created": "{type} '{name}' created", + "activity_log.msg.entity.updated": "{type} '{name}' updated", + "activity_log.msg.entity.deleted": "{type} '{name}' deleted", + "activity_log.entity_type.output_target": "Output Target", + "activity_log.entity_type.device": "Device", + "activity_log.entity_type.picture_source": "Picture Source", + "activity_log.entity_type.color_strip_source": "Color Strip Source", + "activity_log.entity_type.audio_source": "Audio Source", + "activity_log.entity_type.automation": "Automation", + "activity_log.entity_type.scene_preset": "Scene Preset", + "activity_log.entity_type.scene_playlist": "Scene Playlist", + "activity_log.entity_type.sync_clock": "Sync Clock", + "activity_log.entity_type.template": "Template", + "activity_log.entity_type.gradient": "Gradient", + "activity_log.entity_type.cspt": "Processing Template", + "activity_log.entity_type.audio_template": "Audio Template", + "activity_log.entity_type.audio_processing_template": "Audio Processing Template", + "activity_log.entity_type.audio_processing_profile": "Audio Processing Profile", + "activity_log.entity_type.http_endpoint": "HTTP Endpoint", + "activity_log.entity_type.mqtt_source": "MQTT Source", + "activity_log.entity_type.home_assistant_source": "Home Assistant Source", + "activity_log.entity_type.weather_source": "Weather Source", + "activity_log.entity_type.game_integration": "Game Integration", + "activity_log.entity_type.value_source": "Value Source", + "activity_log.entity_type.asset": "Asset", + "api.error.network": "Network error — check your connection", + "api.error.timeout": "Request timed out — please try again", + "api_key.login": "Login", + "app.api_docs": "API Documentation", + "app.connection_lost": "Server unreachable", + "app.connection_retrying": "Attempting to reconnect…", + "app.server_restarting": "Server restarting…", + "app.server_restarting_sub": "Please wait, the server will be back shortly.", + "app.title": "LED Grab", + "app.version": "Version:", + "appearance.bg.applied": "Background effect applied", + "appearance.bg.aurora": "Aurora", + "appearance.bg.grid": "Dot Grid", + "appearance.bg.hint": "Add an ambient background layer behind the interface.", + "appearance.bg.label": "Background Effects", + "appearance.bg.mesh": "Gradient Mesh", + "appearance.bg.noise": "Noise Field", + "appearance.bg.none": "None", + "appearance.bg.plasma": "Plasma", + "appearance.bg.rain": "Digital Rain", + "appearance.bg.scanlines": "Scanlines", + "appearance.bg.stars": "Starfield", + "appearance.bg.warp": "Warp Tunnel", + "appearance.preset.applied": "Style preset applied", + "appearance.preset.arctic": "Arctic", + "appearance.preset.copper": "Copper", + "appearance.preset.default": "Default", + "appearance.preset.ember": "Ember", + "appearance.preset.midnight": "Midnight", + "appearance.preset.monolith": "Monolith", + "appearance.preset.neon": "Neon", + "appearance.preset.ocean": "Ocean", + "appearance.preset.sakura": "Sakura", + "appearance.preset.terminal": "Terminal", + "appearance.preset.vapor": "Vapor", + "appearance.style.hint": "Choose a visual theme — font pairing and color palette applied together.", + "appearance.style.label": "Style Presets", + "aria.cancel": "Cancel", + "aria.close": "Close", + "aria.hint": "Show hint", + "aria.next": "Next", + "aria.previous": "Previous", + "aria.save": "Save", + "asset.confirm_delete": "Delete this asset?", + "asset.deleted": "Asset deleted", + "asset.description": "Description:", + "asset.description.hint": "Optional description for this asset.", + "asset.download": "Download", + "asset.drop_or_browse": "Drop file here or click to browse", + "asset.edit": "Edit Asset", + "asset.error.delete_failed": "Failed to delete asset", + "asset.error.download_failed": "Failed to download asset", + "asset.error.name_required": "Name is required", + "asset.error.no_file": "Please select a file to upload", + "asset.error.play_failed": "Failed to play sound", + "asset.file": "File:", + "asset.file.hint": "Select a file to upload (sound, image, video, or other).", + "asset.group.title": "Assets", + "asset.name": "Name:", + "asset.name.hint": "Display name for this asset.", + "asset.play": "Play", + "asset.prebuilt": "Prebuilt", + "asset.prebuilt_none_to_restore": "All prebuilt assets are already available", + "asset.prebuilt_restored": "{count} prebuilt asset(s) restored", + "asset.restore_prebuilt": "Restore Prebuilt Sounds", + "asset.type.image": "Image", + "asset.type.other": "Other", + "asset.type.sound": "Sound", + "asset.type.video": "Video", + "asset.updated": "Asset updated", + "asset.upload": "Upload Asset", + "asset.uploaded": "Asset uploaded", + "audio_processing.add": "Add Audio Processing Template", + "audio_processing.created": "Audio processing template created", + "audio_processing.delete.confirm": "Are you sure you want to delete this audio processing template?", + "audio_processing.deleted": "Audio processing template deleted", + "audio_processing.description.hint": "Describe what this template does", + "audio_processing.description_label": "Description (optional):", + "audio_processing.description_placeholder": "Describe this template...", + "audio_processing.edit": "Edit Audio Processing Template", + "audio_processing.error.clone_failed": "Failed to clone audio processing template", + "audio_processing.error.delete": "Error deleting audio processing template", + "audio_processing.error.load": "Error loading audio processing template", + "audio_processing.error.required": "Please fill in all required fields", + "audio_processing.error.save_failed": "Failed to save audio processing template", + "audio_processing.filter_count": "Filter count", + "audio_processing.filters_label": "filters", + "audio_processing.name": "Template Name:", + "audio_processing.name.hint": "A descriptive name for this audio processing template", + "audio_processing.name_placeholder": "My Audio Processing Template", + "audio_processing.title": "Audio Processing Templates", + "audio_processing.updated": "Audio processing template updated", + "audio_source.add": "Add Audio Source", + "audio_source.add.capture": "Add Capture Source", + "audio_source.add.processed": "Add Processed Source", + "audio_source.audio_template": "Audio Template:", + "audio_source.audio_template.hint": "Audio capture template that defines which engine and settings to use for this device", + "audio_source.created": "Audio source created", + "audio_source.delete.confirm": "Are you sure you want to delete this audio source?", + "audio_source.deleted": "Audio source deleted", + "audio_source.description": "Description (optional):", + "audio_source.description.hint": "Optional notes about this audio source", + "audio_source.description.placeholder": "Describe this audio source...", + "audio_source.device": "Audio Device:", + "audio_source.device.hint": "Audio input source. Loopback devices capture system audio output; input devices capture microphone or line-in.", + "audio_source.edit": "Edit Audio Source", + "audio_source.edit.capture": "Edit Capture Source", + "audio_source.edit.processed": "Edit Processed Source", + "audio_source.error.load": "Failed to load audio source", + "audio_source.error.name_required": "Please enter a name", + "audio_source.group.capture": "Capture Sources", + "audio_source.group.processed": "Processed Sources", + "audio_source.name": "Name:", + "audio_source.name.hint": "A descriptive name for this audio source", + "audio_source.name.placeholder": "System Audio", + "audio_source.parent": "Input Audio Source:", + "audio_source.parent.hint": "Audio source to apply processing filters to", + "audio_source.processing_template": "Processing Template:", + "audio_source.processing_template.hint": "Audio processing template with filters to apply to the input source", + "audio_source.refresh_devices": "Refresh devices", + "audio_source.test": "Test", + "audio_source.test.beat": "Beat", + "audio_source.test.connecting": "Connecting...", + "audio_source.test.error": "Audio test failed", + "audio_source.test.peak": "Peak", + "audio_source.test.rms": "RMS", + "audio_source.test.title": "Test Audio Source", + "audio_source.title": "Audio Sources", + "audio_source.type": "Type:", + "audio_source.type.capture": "Capture", + "audio_source.type.hint": "Capture wraps a physical audio device. Processed applies audio processing filters to another source.", + "audio_source.type.processed": "Processed", + "audio_source.updated": "Audio source updated", + "audio_template.add": "Add Audio Template", + "audio_template.config": "Configuration", + "audio_template.config.show": "Show configuration", + "audio_template.created": "Audio template created", + "audio_template.delete.confirm": "Are you sure you want to delete this audio template?", + "audio_template.deleted": "Audio template deleted", + "audio_template.description.label": "Description (optional):", + "audio_template.description.placeholder": "Describe this template...", + "audio_template.edit": "Edit Audio Template", + "audio_template.engine": "Audio Engine:", + "audio_template.engine.hint": "Select the audio capture backend to use. WASAPI is Windows-only with loopback support. Sounddevice is cross-platform.", + "audio_template.engine.unavailable": "Unavailable", + "audio_template.engine.unavailable.hint": "This engine is not available on your system", + "audio_template.error.clone_failed": "Failed to clone audio template", + "audio_template.error.delete": "Failed to delete audio template", + "audio_template.error.engines": "Failed to load audio engines", + "audio_template.error.load": "Failed to load audio templates", + "audio_template.error.load_failed": "Failed to load audio template", + "audio_template.error.required": "Please fill in all required fields", + "audio_template.error.save_failed": "Failed to save audio template", + "audio_template.name": "Template Name:", + "audio_template.name.placeholder": "My Audio Template", + "audio_template.test": "Test", + "audio_template.test.device": "Audio Device:", + "audio_template.test.device.hint": "Select which audio device to capture from during the test", + "audio_template.test.run": "Run", + "audio_template.test.title": "Test Audio Template", + "audio_template.title": "Audio Templates", + "audio_template.updated": "Audio template updated", + "auth.authenticated": "● Authenticated", + "auth.button.cancel": "Cancel", + "auth.button.login": "Login", + "auth.error.invalid": "Invalid API key. Please try again.", + "auth.error.required": "Please enter an API key", + "auth.hint": "Your API key will be stored securely in your browser\u0027s local storage.", + "auth.label": "API Key:", + "auth.login": "Login", + "auth.logout": "Logout", + "auth.logout.confirm": "Are you sure you want to logout?", + "auth.logout.success": "Logged out successfully", + "auth.message": "Please enter your API key to authenticate and access the LED Grab.", + "auth.placeholder": "Enter your API key...", + "auth.please_login": "Please login to view", + "auth.prompt_enter": "Enter your API key:", + "auth.prompt_update": "Current API key is set. Enter new key to update or leave blank to remove:", + "auth.session_expired": "Your session has expired or the API key is invalid. Please login again.", + "auth.success": "Logged in successfully!", + "auth.title": "Login to LED Grab", + "auth.toggle_password": "Toggle password visibility", + "autocal.btn.back": "Back", + "autocal.btn.cancel": "Cancel", + "autocal.btn.mark_corner": "Mark corner", + "autocal.btn.next": "Next", + "autocal.btn.save": "Save", + "autocal.btn.solve": "Solve", + "autocal.btn.step_back": "Step back", + "autocal.btn.step_fwd": "Step forward", + "autocal.corner.desc": "Which corner is LED #0 (the very first LED of the strip)?", + "autocal.corner.led_index": "LED 0 position", + "autocal.corner.title": "Start Corner", + "autocal.corners.desc": "Sweep to the next corner then tap Mark. Corner: {corner}", + "autocal.corners.desc_complete": "All 4 corners marked! Review and continue.", + "autocal.corners.index_label": "LED index", + "autocal.corners.title": "Mark Corners — {remaining} remaining", + "autocal.device.desc": "Choose the WLED/device that drives this LED strip. The strip will briefly light up during calibration.", + "autocal.device.label": "Device", + "autocal.device.title": "Select Device", + "autocal.direction.desc": "Which direction does the strip run from the start corner?", + "autocal.direction.title": "Strip Direction — Step {step}", + "autocal.error.css_required": "Auto-calibration requires a Color Strip Source (not a device-only target).", + "autocal.error.no_device": "Please select a device to continue.", + "autocal.error.position_failed": "Failed to move to LED position.", + "autocal.error.save_failed": "Failed to save calibration.", + "autocal.error.session_start_failed": "Failed to start calibration session.", + "autocal.error.session_stop_failed": "Failed to stop calibration session.", + "autocal.error.solve_failed": "Failed to solve calibration.", + "autocal.modal.title": "Auto-Calibrate Strip", + "autocal.position.bottom_left": "Bottom-left", + "autocal.position.bottom_right": "Bottom-right", + "autocal.position.top_left": "Top-left", + "autocal.position.top_right": "Top-right", + "autocal.preview.bottom": "Bottom LEDs", + "autocal.preview.desc": "Review the detected layout and save to the strip source.", + "autocal.preview.left": "Left LEDs", + "autocal.preview.right": "Right LEDs", + "autocal.preview.start": "Start corner", + "autocal.preview.title": "Preview \u0026 Save", + "autocal.preview.top": "Top LEDs", + "autocal.preview.total": "Total LEDs", + "autocal.saved": "Calibration saved successfully.", + "autocal.trigger.hint": "Automatically detect LED positions by walking the strip", + "autocal.trigger.label": "Auto-calibrate", + "automation.disabled": "Automation disabled", + "automation.enabled": "Automation enabled", + "automations.action.disable": "Disable", + "automations.add": "Add Automation", + "automations.created": "Automation created", + "automations.deactivation_mode": "Deactivation:", + "automations.deactivation_mode.fallback_scene": "Fallback", + "automations.deactivation_mode.fallback_scene.desc": "Activate a fallback scene", + "automations.deactivation_mode.hint": "What happens when conditions stop matching", + "automations.deactivation_mode.none": "None", + "automations.deactivation_mode.none.desc": "Keep current state", + "automations.deactivation_mode.revert": "Revert", + "automations.deactivation_mode.revert.desc": "Restore previous state", + "automations.deactivation_scene": "Fallback Scene:", + "automations.deactivation_scene.hint": "Scene to activate when this automation deactivates", + "automations.delete.confirm": "Delete automation \"{name}\"?", + "automations.deleted": "Automation deleted", + "automations.edit": "Edit Automation", + "automations.empty": "No automations configured. Create one to automate scene activation.", + "automations.enabled": "Enabled:", + "automations.enabled.hint": "Disabled automations won\u0027t activate even when conditions are met", + "automations.error.clone_failed": "Failed to clone automation", + "automations.error.delete_failed": "Failed to delete automation", + "automations.error.load_failed": "Failed to load automation", + "automations.error.name_required": "Name is required", + "automations.error.save_failed": "Failed to save automation", + "automations.error.toggle_failed": "Failed to toggle automation", + "automations.last_activated": "Last activated", + "automations.logic.all": "ALL", + "automations.logic.and": " AND ", + "automations.logic.any": "ANY", + "automations.logic.or": " OR ", + "automations.name": "Name:", + "automations.name.hint": "A descriptive name for this automation", + "automations.name.placeholder": "My Automation", + "automations.rule.application": "Application", + "automations.rule.application.apps": "Applications:", + "automations.rule.application.apps.hint": "Process names, one per line (e.g. firefox.exe)", + "automations.rule.application.apps.hint_android": "Package names, one per line (e.g. com.netflix.mediaclient)", + "automations.rule.application.browse": "Browse", + "automations.rule.application.desc": "App running/focused", + "automations.rule.application.match_type": "Match Type:", + "automations.rule.application.match_type.fullscreen": "Fullscreen", + "automations.rule.application.match_type.fullscreen.desc": "Any fullscreen app", + "automations.rule.application.match_type.hint": "How to detect the application", + "automations.rule.application.match_type.running": "Running", + "automations.rule.application.match_type.running.desc": "Process is active", + "automations.rule.application.match_type.topmost": "Topmost", + "automations.rule.application.match_type.topmost.desc": "Foreground window", + "automations.rule.application.match_type.topmost_fullscreen": "Topmost + FS", + "automations.rule.application.match_type.topmost_fullscreen.desc": "Foreground + fullscreen", + "automations.rule.application.no_apps": "No apps found", + "automations.rule.application.no_processes": "No processes found", + "automations.rule.application.search": "Filter processes...", + "automations.rule.application.search_apps": "Filter apps...", + "automations.rule.application.usage_access_required": "Needs Usage Access. On your LedGrab TV, open the app and tap \u0027Grant usage access\u0027.", + "automations.rule.display_state": "Display State", + "automations.rule.display_state.desc": "Monitor on/off", + "automations.rule.display_state.off": "Off (sleeping)", + "automations.rule.display_state.on": "On", + "automations.rule.display_state.state": "Monitor State:", + "automations.rule.ha.match_mode.contains.desc": "State must contain the text", + "automations.rule.ha.match_mode.exact.desc": "State must match exactly", + "automations.rule.ha.match_mode.regex.desc": "State must match the regex pattern", + "automations.rule.home_assistant": "Home Assistant", + "automations.rule.home_assistant.desc": "HA entity state", + "automations.rule.home_assistant.entity_id": "Entity ID:", + "automations.rule.home_assistant.ha_source": "HA Source:", + "automations.rule.home_assistant.hint": "Activate when a Home Assistant entity matches the specified state", + "automations.rule.home_assistant.match_mode": "Match Mode:", + "automations.rule.home_assistant.state": "State:", + "automations.rule.http_poll": "HTTP Poll", + "automations.rule.http_poll.desc": "Activate when the latest extracted value from an HTTP value source matches.", + "automations.rule.http_poll.hint": "Compares the latest extracted value against your input. The value source decides what gets extracted (raw body or JSON path).", + "automations.rule.http_poll.no_source": "(no source)", + "automations.rule.http_poll.operator": "Operator", + "automations.rule.http_poll.operator.contains": "Contains", + "automations.rule.http_poll.operator.contains.desc": "Substring match.", + "automations.rule.http_poll.operator.equals": "Equals", + "automations.rule.http_poll.operator.equals.desc": "Exact string match.", + "automations.rule.http_poll.operator.exists": "Exists", + "automations.rule.http_poll.operator.exists.desc": "Activates whenever a value is successfully extracted (ignores the value).", + "automations.rule.http_poll.operator.gt": "Greater than", + "automations.rule.http_poll.operator.gt.desc": "Numeric comparison (\u003e) — requires numeric output.", + "automations.rule.http_poll.operator.lt": "Less than", + "automations.rule.http_poll.operator.lt.desc": "Numeric comparison (\u003c) — requires numeric output.", + "automations.rule.http_poll.operator.not_equals": "Not equals", + "automations.rule.http_poll.operator.not_equals.desc": "Activates when the value is anything other than this.", + "automations.rule.http_poll.operator.regex": "Regex", + "automations.rule.http_poll.operator.regex.desc": "JavaScript-style regular expression.", + "automations.rule.http_poll.raw_body": "Raw body", + "automations.rule.http_poll.value": "Value", + "automations.rule.http_poll.value.placeholder": "playing", + "automations.rule.http_poll.value_source": "HTTP Value Source", + "automations.rule.mqtt": "MQTT", + "automations.rule.mqtt.desc": "MQTT message", + "automations.rule.mqtt.hint": "Activate when an MQTT topic receives a matching payload", + "automations.rule.mqtt.match_mode": "Match Mode:", + "automations.rule.mqtt.match_mode.contains": "Contains", + "automations.rule.mqtt.match_mode.exact": "Exact", + "automations.rule.mqtt.match_mode.regex": "Regex", + "automations.rule.mqtt.payload": "Payload:", + "automations.rule.mqtt.topic": "Topic:", + "automations.rule.startup": "Startup", + "automations.rule.startup.desc": "On server start", + "automations.rule.startup.hint": "Activates when the server starts and stays active while enabled.", + "automations.rule.system_idle": "System Idle", + "automations.rule.system_idle.desc": "User idle/active", + "automations.rule.system_idle.idle_minutes": "Idle Timeout (minutes):", + "automations.rule.system_idle.mode": "Trigger Mode:", + "automations.rule.system_idle.when_active": "When active", + "automations.rule.system_idle.when_active.desc": "Fires while the user is actively using the system", + "automations.rule.system_idle.when_idle": "When idle", + "automations.rule.system_idle.when_idle.desc": "Fires once the user has been idle past the timeout", + "automations.rule.time_of_day": "Time of Day", + "automations.rule.time_of_day.days": "Active days", + "automations.rule.time_of_day.days_hint": "Leave all unselected for every day. Overnight windows count toward the day they start on.", + "automations.rule.time_of_day.desc": "Time range", + "automations.rule.time_of_day.end_time": "End Time:", + "automations.rule.time_of_day.overnight_hint": "For overnight ranges (e.g. 22:00–06:00), set start time after end time.", + "automations.rule.time_of_day.start_time": "Start Time:", + "automations.rule.time_of_day.timezone": "Timezone", + "automations.rule.time_of_day.timezone.placeholder": "Server local (e.g. Europe/Berlin)", + "automations.rule.webhook": "Webhook", + "automations.rule.webhook.copied": "Copied!", + "automations.rule.webhook.copy": "Copy", + "automations.rule.webhook.desc": "HTTP callback", + "automations.rule.webhook.hint": "Activate via an HTTP call from external services (Home Assistant, IFTTT, curl, etc.)", + "automations.rule.webhook.save_first": "Save the automation first to generate a webhook URL", + "automations.rule.webhook.url": "Webhook URL:", + "automations.rule_logic": "Rule Logic:", + "automations.rule_logic.and": "All rules (AND)", + "automations.rule_logic.and.desc": "Triggers only when all match", + "automations.rule_logic.hint": "How multiple rules are combined: ANY (OR) or ALL (AND)", + "automations.rule_logic.or": "Any rule (OR)", + "automations.rule_logic.or.desc": "Triggers when any rule matches", + "automations.rules": "Rules:", + "automations.rules.add": "Add Rule", + "automations.rules.empty": "No rules — automation is always active when enabled", + "automations.rules.hint": "Rules that determine when this automation activates", + "automations.scene": "Scene:", + "automations.scene.hint": "Scene preset to activate when conditions are met", + "automations.scene.none_available": "No scenes available", + "automations.scene.none_selected": "None (no scene)", + "automations.scene.search_placeholder": "Search scenes...", + "automations.section.action": "Action", + "automations.section.deactivation": "Deactivation", + "automations.section.triggers": "Triggers", + "automations.status.active": "Active", + "automations.status.disabled": "Disabled", + "automations.status.inactive": "Inactive", + "automations.title": "Automations", + "automations.updated": "Automation updated", + "bg.anim.toggle": "Toggle ambient background", + "bindable.none": "None (static value)", + "bindable.toggle": "Toggle value source binding", + "bulk.cancel": "Cancel", + "bulk.confirm_delete.one": "Delete {count} item?", + "bulk.confirm_delete.other": "Delete {count} items?", + "bulk.delete": "Delete", + "bulk.deselect_all": "Deselect all", + "bulk.disable": "Disable", + "bulk.enable": "Enable", + "bulk.processing": "Processing…", + "bulk.select": "Select", + "bulk.select_all": "Select all", + "bulk.selected_count.one": "{count} selected", + "bulk.selected_count.other": "{count} selected", + "bulk.start": "Start", + "bulk.stop": "Stop", + "calibration.advanced.border_width": "Depth (px):", + "calibration.advanced.border_width.hint": "How many pixels deep from the edge to sample. Larger values capture more of the screen interior.", + "calibration.advanced.canvas_hint": "Drag monitors to reposition. Click edges to select lines. Scroll to zoom, drag empty space to pan.", + "calibration.advanced.edge": "Edge:", + "calibration.advanced.edge.hint": "Which screen edge to sample pixels from", + "calibration.advanced.led_count": "LEDs:", + "calibration.advanced.led_count.hint": "Number of LEDs mapped to this line", + "calibration.advanced.line_properties": "Line Properties", + "calibration.advanced.lines_title": "Lines", + "calibration.advanced.no_lines_warning": "Add at least one line", + "calibration.advanced.picture_source": "Source:", + "calibration.advanced.picture_source.hint": "The picture source (monitor) this line samples from", + "calibration.advanced.reset_view": "Reset view", + "calibration.advanced.reverse": "Reverse", + "calibration.advanced.span_end": "Span End:", + "calibration.advanced.span_end.hint": "Where sampling ends along the edge (0 = start, 1 = end). Together with Span Start, defines the active portion.", + "calibration.advanced.span_start": "Span Start:", + "calibration.advanced.span_start.hint": "Where sampling begins along the edge (0 = start, 1 = end). Use to cover only part of an edge.", + "calibration.advanced.switch_to_simple": "Switch to Simple", + "calibration.advanced.title": "Advanced Calibration", + "calibration.border_width": "Border (px):", + "calibration.border_width.hint": "How many pixels from the screen edge to sample for LED colors (1-100)", + "calibration.button.cancel": "Cancel", + "calibration.button.save": "Save", + "calibration.corner.bottom_left": "Set start position: bottom left", + "calibration.corner.bottom_right": "Set start position: bottom right", + "calibration.corner.top_left": "Set start position: top left", + "calibration.corner.top_right": "Set start position: top right", + "calibration.direction": "Direction:", + "calibration.direction.clockwise": "Clockwise", + "calibration.direction.counterclockwise": "Counterclockwise", + "calibration.direction.toggle": "Toggle direction", + "calibration.edge.bottom": "Toggle bottom edge test LEDs", + "calibration.edge.left": "Toggle left edge test LEDs", + "calibration.edge.right": "Toggle right edge test LEDs", + "calibration.edge.top": "Toggle top edge test LEDs", + "calibration.edge_inputs.toggle": "Toggle edge LED inputs", + "calibration.error.css_load_failed": "Failed to load color strip source", + "calibration.error.led_count_exceeded": "Calibrated LEDs exceed the total LED count", + "calibration.error.led_count_mismatch": "Total LEDs must equal the device LED count", + "calibration.error.load_failed": "Failed to load calibration", + "calibration.error.save_failed": "Failed to save calibration", + "calibration.error.test_toggle_failed": "Failed to toggle test edge", + "calibration.failed": "Failed to save calibration", + "calibration.leds.bottom": "Bottom LEDs:", + "calibration.leds.left": "Left LEDs:", + "calibration.leds.right": "Right LEDs:", + "calibration.leds.top": "Top LEDs:", + "calibration.mode.advanced": "Advanced", + "calibration.mode.simple": "Simple", + "calibration.offset": "LED Offset:", + "calibration.offset.hint": "Distance from physical LED 0 to the start corner (along strip direction)", + "calibration.overlay_toggle": "Overlay", + "calibration.position.bottom_left": "Bottom Left", + "calibration.position.bottom_right": "Bottom Right", + "calibration.position.top_left": "Top Left", + "calibration.position.top_right": "Top Right", + "calibration.roi": "Capture region (%):", + "calibration.roi.height": "Height (%)", + "calibration.roi.hint": "Sample only this sub-rectangle of the screen so a taskbar, HUD, or black bars don\u0027t pollute the border colours. Full frame = X/Y 0, Width/Height 100.", + "calibration.roi.width": "Width (%)", + "calibration.roi.x": "X (%)", + "calibration.roi.y": "Y (%)", + "calibration.saved": "Calibration saved", + "calibration.skip_end": "Skip LEDs (End):", + "calibration.skip_end.hint": "Number of LEDs to turn off at the end of the strip (0 = none)", + "calibration.skip_start": "Skip LEDs (Start):", + "calibration.skip_start.hint": "Number of LEDs to turn off at the beginning of the strip (0 = none)", + "calibration.start_position": "Starting Position:", + "calibration.switch_to_advanced": "Switch to Advanced", + "calibration.tip.border_width": "How many pixels from the screen edge to sample for LED colors", + "calibration.tip.direction": "Toggle LED strip direction (clockwise / counterclockwise)", + "calibration.tip.led_count": "Enter LED count per edge", + "calibration.tip.offset": "Set LED offset — distance from LED 0 to the start corner", + "calibration.tip.overlay": "Toggle screen overlay to see LED positions and numbering on your monitor", + "calibration.tip.skip_leds_end": "Skip LEDs at the end of the strip — skipped LEDs stay off", + "calibration.tip.skip_leds_start": "Skip LEDs at the start of the strip — skipped LEDs stay off", + "calibration.tip.span": "Drag green bars to adjust coverage span", + "calibration.tip.start_corner": "Click a corner to set the start position", + "calibration.tip.test": "Click an edge to toggle test LEDs", + "calibration.tip.toggle_inputs": "Click total LED count to toggle edge inputs", + "calibration.title": "LED Calibration", + "calibration.tutorial.start": "Start tutorial", + "card_mode.comfortable": "Comfortable", + "card_mode.compact": "Compact", + "card_mode.dense": "Dense", + "card_mode.row": "List", + "card_mode.tooltip": "Card size", + "color_strip.add": "Add", + "color_strip.animation": "Animation", + "color_strip.animation.speed": "Speed:", + "color_strip.animation.speed.hint": "Animation speed multiplier. 1.0 ≈ one cycle per second for Breathing; higher values cycle faster.", + "color_strip.animation.type": "Effect:", + "color_strip.animation.type.breathing": "Breathing", + "color_strip.animation.type.breathing.desc": "Smooth brightness fade in and out", + "color_strip.animation.type.candle": "Candle", + "color_strip.animation.type.candle.desc": "Warm flickering candle-like glow", + "color_strip.animation.type.gradient_shift": "Gradient Shift", + "color_strip.animation.type.gradient_shift.desc": "Slides the gradient along the strip", + "color_strip.animation.type.hint": "Animation effect to apply.", + "color_strip.animation.type.hue_rotate": "Hue Rotate", + "color_strip.animation.type.hue_rotate.desc": "Smoothly rotates all pixel hues while preserving saturation and brightness", + "color_strip.animation.type.noise_perturb": "Noise Perturb", + "color_strip.animation.type.noise_perturb.desc": "Perturbs gradient stop positions with organic noise each frame", + "color_strip.animation.type.none": "None (no animation effect)", + "color_strip.animation.type.none.desc": "Static colors with no animation", + "color_strip.animation.type.pulse": "Pulse", + "color_strip.animation.type.pulse.desc": "Sharp brightness pulse with quick fade", + "color_strip.animation.type.rainbow_fade": "Rainbow Fade", + "color_strip.animation.type.rainbow_fade.desc": "Cycles through the entire hue spectrum", + "color_strip.animation.type.sparkle": "Sparkle", + "color_strip.animation.type.sparkle.desc": "Random LEDs flash briefly", + "color_strip.animation.type.strobe": "Strobe", + "color_strip.animation.type.strobe.desc": "Rapid on/off flashing", + "color_strip.animation.type.wave": "Wave", + "color_strip.animation.type.wave.desc": "Sinusoidal brightness wave moving along the strip", + "color_strip.api_input.endpoints": "Push Endpoints:", + "color_strip.api_input.endpoints.hint": "Use these URLs to push LED color data from your external application. REST accepts JSON, WebSocket accepts both JSON and raw binary frames.", + "color_strip.api_input.fallback_color": "Fallback Color:", + "color_strip.api_input.fallback_color.hint": "Color to display when no data has been received within the timeout period. LEDs will show this color on startup and after the connection is lost.", + "color_strip.api_input.interpolation": "LED Interpolation:", + "color_strip.api_input.interpolation.hint": "How to resize incoming LED data when its count differs from the device\u0027s LED count. Linear gives smooth blending, Nearest preserves sharp edges, None truncates or zero-pads.", + "color_strip.api_input.interpolation.linear": "Linear", + "color_strip.api_input.interpolation.linear.desc": "Smooth blending between LEDs", + "color_strip.api_input.interpolation.nearest": "Nearest", + "color_strip.api_input.interpolation.nearest.desc": "Sharp edges, no blending", + "color_strip.api_input.interpolation.none": "None", + "color_strip.api_input.interpolation.none.desc": "Truncate or zero-pad", + "color_strip.api_input.save_first": "Save the source first to see the push endpoint URLs.", + "color_strip.api_input.timeout": "Timeout (seconds):", + "color_strip.api_input.timeout.hint": "How long to wait for new color data before reverting to the fallback color. Set to 0 to never time out.", + "color_strip.audio.beat_decay": "Beat Decay:", + "color_strip.audio.beat_decay.hint": "How quickly the beat pulse fades. Lower values = longer fade, higher = snappier response.", + "color_strip.audio.color": "Base Color:", + "color_strip.audio.color.hint": "Low-level color for VU meter bar.", + "color_strip.audio.color_peak": "Peak Color:", + "color_strip.audio.color_peak.hint": "High-level color at the top of the VU meter bar.", + "color_strip.audio.mirror": "Mirror:", + "color_strip.audio.mirror.hint": "Mirror spectrum from center outward: bass in the middle, treble at the edges.", + "color_strip.audio.palette": "Palette:", + "color_strip.audio.palette.hint": "Color palette used for spectrum bars or beat pulse coloring.", + "color_strip.audio.sensitivity": "Sensitivity:", + "color_strip.audio.sensitivity.hint": "Gain multiplier for audio levels. Higher values make LEDs react to quieter sounds.", + "color_strip.audio.smoothing": "Smoothing:", + "color_strip.audio.smoothing.hint": "Temporal smoothing between frames. Higher values produce smoother but slower-reacting visuals.", + "color_strip.audio.source": "Audio Source:", + "color_strip.audio.source.hint": "Audio source for this visualization. Can be a multichannel (device) or mono (single channel) source. Create and manage audio sources in the Sources tab.", + "color_strip.audio.visualization": "Visualization:", + "color_strip.audio.visualization.hint": "How audio data is rendered to LEDs.", + "color_strip.audio.viz.beat_pulse": "Beat Pulse", + "color_strip.audio.viz.beat_pulse.desc": "All LEDs pulse on the beat", + "color_strip.audio.viz.energy_gradient": "Energy Gradient", + "color_strip.audio.viz.energy_gradient.desc": "Gradient intensity follows audio energy", + "color_strip.audio.viz.pulse_on_beat": "Pulse on Beat", + "color_strip.audio.viz.pulse_on_beat.desc": "LEDs pulse with each detected beat", + "color_strip.audio.viz.spectrum": "Spectrum Analyzer", + "color_strip.audio.viz.spectrum.desc": "Frequency bars across the strip", + "color_strip.audio.viz.spectrum_bands": "Spectrum Bands", + "color_strip.audio.viz.spectrum_bands.desc": "Grouped frequency bands across the strip", + "color_strip.audio.viz.strobe_on_drop": "Strobe on Drop", + "color_strip.audio.viz.strobe_on_drop.desc": "Flash strobe effect on bass drops", + "color_strip.audio.viz.vu_meter": "VU Meter", + "color_strip.audio.viz.vu_meter.desc": "Volume level fills the strip", + "color_strip.brightness": "Brightness:", + "color_strip.brightness.hint": "Output brightness multiplier (0=off, 1=unchanged, 2=double). Applied after color extraction.", + "color_strip.candlelight.color": "Base Color:", + "color_strip.candlelight.color.hint": "The warm base color of the candle flame. Default is a natural warm amber.", + "color_strip.candlelight.intensity": "Flicker Intensity:", + "color_strip.candlelight.intensity.hint": "How much the candles flicker. Low values produce a gentle glow, high values simulate a windy candle.", + "color_strip.candlelight.num_candles": "candles", + "color_strip.candlelight.num_candles.hint": "How many independent candle sources along the strip. Each flickers with its own pattern.", + "color_strip.candlelight.num_candles_label": "Number of Candles:", + "color_strip.candlelight.speed": "Flicker Speed:", + "color_strip.candlelight.speed.hint": "Speed of the flicker animation. Higher values produce faster, more restless flames.", + "color_strip.candlelight.type": "Candle Type:", + "color_strip.candlelight.type.bonfire": "Bonfire", + "color_strip.candlelight.type.bonfire.desc": "Large, chaotic fire with extra warmth", + "color_strip.candlelight.type.default": "Default", + "color_strip.candlelight.type.default.desc": "Standard candle flicker", + "color_strip.candlelight.type.hint": "Preset that adjusts flicker behavior without changing other settings.", + "color_strip.candlelight.type.taper": "Taper", + "color_strip.candlelight.type.taper.desc": "Tall, steady candle with reduced flicker", + "color_strip.candlelight.type.votive": "Votive", + "color_strip.candlelight.type.votive.desc": "Small, flickery candle with narrow glow", + "color_strip.candlelight.wind": "Wind:", + "color_strip.candlelight.wind.hint": "Wind simulation strength. Higher values create correlated gusts that make all candles flicker together.", + "color_strip.clock": "Sync Clock:", + "color_strip.clock.hint": "Link to a sync clock to synchronize animation timing across sources. Speed is controlled on the clock.", + "color_strip.color_corrections": "Color Corrections", + "color_strip.composite.add_layer": "Add Layer", + "color_strip.composite.blend_mode": "Blend", + "color_strip.composite.blend_mode.add": "Add", + "color_strip.composite.blend_mode.add.desc": "Brightens by adding colors", + "color_strip.composite.blend_mode.difference": "Difference", + "color_strip.composite.blend_mode.difference.desc": "Absolute color difference", + "color_strip.composite.blend_mode.exclusion": "Exclusion", + "color_strip.composite.blend_mode.exclusion.desc": "Like difference, lower contrast", + "color_strip.composite.blend_mode.hard_light": "Hard Light", + "color_strip.composite.blend_mode.hard_light.desc": "Strong contrast, vivid colors", + "color_strip.composite.blend_mode.multiply": "Multiply", + "color_strip.composite.blend_mode.multiply.desc": "Darkens by multiplying colors", + "color_strip.composite.blend_mode.normal": "Normal", + "color_strip.composite.blend_mode.normal.desc": "Standard alpha blending", + "color_strip.composite.blend_mode.overlay": "Overlay", + "color_strip.composite.blend_mode.overlay.desc": "Multiply darks, screen lights", + "color_strip.composite.blend_mode.override": "Override", + "color_strip.composite.blend_mode.override.desc": "Black = transparent, bright = opaque", + "color_strip.composite.blend_mode.screen": "Screen", + "color_strip.composite.blend_mode.screen.desc": "Brightens, inverse of multiply", + "color_strip.composite.blend_mode.soft_light": "Soft Light", + "color_strip.composite.blend_mode.soft_light.desc": "Gentle contrast adjustment", + "color_strip.composite.brightness": "Brightness", + "color_strip.composite.brightness.none": "None (full brightness)", + "color_strip.composite.enabled": "Enabled", + "color_strip.composite.error.min_layers": "At least 1 layer is required", + "color_strip.composite.error.no_source": "Each layer must have a source selected", + "color_strip.composite.layers": "Layers:", + "color_strip.composite.layers.hint": "Stack multiple color strip sources. First layer is the bottom, last is the top. Each layer can have its own blend mode and opacity.", + "color_strip.composite.layers_count": "layers", + "color_strip.composite.opacity": "Opacity", + "color_strip.composite.processing": "Processing", + "color_strip.composite.range": "LED Range", + "color_strip.composite.range_end": "End", + "color_strip.composite.range_start": "Start", + "color_strip.composite.reverse": "Reverse", + "color_strip.composite.source": "Source", + "color_strip.created": "Color strip source created", + "color_strip.daylight.latitude": "Latitude:", + "color_strip.daylight.latitude.hint": "Your geographic latitude (-90 to 90). Affects sunrise/sunset timing in real-time mode.", + "color_strip.daylight.longitude": "Longitude:", + "color_strip.daylight.longitude.hint": "Your geographic longitude (-180 to 180). Adjusts solar noon offset for accurate sunrise/sunset timing.", + "color_strip.daylight.real_time": "Real Time", + "color_strip.daylight.speed": "Speed:", + "color_strip.daylight.speed.hint": "Cycle speed multiplier. 1.0 = full day/night cycle in ~4 minutes. Higher values cycle faster.", + "color_strip.daylight.use_real_time": "Use Real Time:", + "color_strip.daylight.use_real_time.hint": "When enabled, LED color matches the actual time of day on this computer. Speed setting is ignored.", + "color_strip.delete.confirm": "Are you sure you want to delete this color strip source?", + "color_strip.delete.referenced": "Cannot delete: this source is in use by a target", + "color_strip.deleted": "Color strip source deleted", + "color_strip.edit": "Edit", + "color_strip.effect.aurora": "Aurora", + "color_strip.effect.aurora.desc": "Layered noise bands that drift and blend — northern lights style", + "color_strip.effect.bouncing_ball": "Bouncing Ball", + "color_strip.effect.bouncing_ball.desc": "Physics-simulated balls bouncing with gravity", + "color_strip.effect.color": "Meteor Color:", + "color_strip.effect.color.hint": "Head color for the meteor effect.", + "color_strip.effect.comet": "Comet", + "color_strip.effect.comet.desc": "Multiple comets with curved, pulsing tails", + "color_strip.effect.custom_palette": "Custom Palette:", + "color_strip.effect.custom_palette.hint": "JSON array of [position, R, G, B] stops, e.g. [[0,0,0,0],[0.5,255,0,0],[1,255,255,0]]", + "color_strip.effect.fire": "Fire", + "color_strip.effect.fire.desc": "Cellular automaton simulating rising flames with heat diffusion", + "color_strip.effect.fireworks": "Fireworks", + "color_strip.effect.fireworks.desc": "Rockets launch and explode into colorful bursts", + "color_strip.effect.intensity": "Intensity:", + "color_strip.effect.intensity.hint": "Effect intensity — controls spark rate (fire), tail decay (meteor), or brightness range (aurora).", + "color_strip.effect.lava_lamp": "Lava Lamp", + "color_strip.effect.lava_lamp.desc": "Slow-moving colored blobs that merge and separate", + "color_strip.effect.meteor": "Meteor", + "color_strip.effect.meteor.desc": "Bright head travels along the strip with an exponential-decay tail", + "color_strip.effect.mirror": "Mirror:", + "color_strip.effect.mirror.hint": "Bounce mode — the meteor reverses direction at strip ends instead of wrapping.", + "color_strip.effect.noise": "Noise", + "color_strip.effect.noise.desc": "Scrolling fractal value noise mapped to a palette", + "color_strip.effect.palette": "Palette:", + "color_strip.effect.palette.hint": "Color palette used to map effect values to RGB colors.", + "color_strip.effect.plasma": "Plasma", + "color_strip.effect.plasma.desc": "Overlapping sine waves mapped to a palette — classic demo-scene effect", + "color_strip.effect.rain": "Rain", + "color_strip.effect.rain.desc": "Raindrops fall down the strip with trailing tails", + "color_strip.effect.scale": "Scale:", + "color_strip.effect.scale.hint": "Spatial scale — wave frequency (plasma), zoom level (noise), or band width (aurora).", + "color_strip.effect.sparkle_rain": "Sparkle Rain", + "color_strip.effect.sparkle_rain.desc": "Twinkling star field with smooth fade-in/fade-out", + "color_strip.effect.speed": "Speed:", + "color_strip.effect.speed.hint": "Speed multiplier for the effect animation (0.1 = very slow, 10.0 = very fast).", + "color_strip.effect.type": "Effect Type:", + "color_strip.effect.type.hint": "Choose the procedural algorithm.", + "color_strip.effect.wave_interference": "Wave Interference", + "color_strip.effect.wave_interference.desc": "Two counter-propagating waves creating interference patterns", + "color_strip.error.clone_failed": "Failed to clone color strip source", + "color_strip.error.delete_failed": "Failed to delete color strip source", + "color_strip.error.editor_open_failed": "Failed to open color strip editor", + "color_strip.error.name_required": "Please enter a name", + "color_strip.fps": "Target FPS:", + "color_strip.fps.hint": "Target frames per second for LED color updates (10-90)", + "color_strip.frame_interpolation": "Frame Interpolation:", + "color_strip.frame_interpolation.hint": "Blends between consecutive captured frames to produce output at the full target FPS even when capture rate is lower. Reduces visible stepping on slow ambient transitions.", + "color_strip.game_event.error.no_integration": "Please select a game integration.", + "color_strip.game_event.event_mappings": "Event Mappings:", + "color_strip.game_event.event_mappings.hint": "Override or add event-to-effect mappings for this source. These supplement the integration-level mappings.", + "color_strip.game_event.idle_color": "Idle Color:", + "color_strip.game_event.idle_color.hint": "LED color when no game events are active.", + "color_strip.game_event.integration": "Game Integration:", + "color_strip.game_event.integration.hint": "Select the game integration that provides events for this source.", + "color_strip.gamma": "Gamma:", + "color_strip.gamma.hint": "Gamma correction (1=none, \u003c1=brighter midtones, \u003e1=darker midtones)", + "color_strip.gradient.add_stop": "+ Add Stop", + "color_strip.gradient.bidir.hint": "Add a second color on the right side of this stop to create a hard edge in the gradient.", + "color_strip.gradient.easing": "Easing:", + "color_strip.gradient.easing.cubic": "Cubic", + "color_strip.gradient.easing.cubic.desc": "Cubic ease — accelerating blend curve", + "color_strip.gradient.easing.ease_in_out": "Smooth", + "color_strip.gradient.easing.ease_in_out.desc": "S-curve: slow start and end, fast middle", + "color_strip.gradient.easing.hint": "Controls how colors blend between gradient stops.", + "color_strip.gradient.easing.linear": "Linear", + "color_strip.gradient.easing.linear.desc": "Constant-rate blending between stops", + "color_strip.gradient.easing.step": "Step", + "color_strip.gradient.easing.step.desc": "Hard jumps between colors with no blending", + "color_strip.gradient.error.no_gradient": "Please select a gradient", + "color_strip.gradient.min_stops": "Gradient must have at least 2 stops", + "color_strip.gradient.position": "Position (0.0–1.0)", + "color_strip.gradient.preset": "Preset:", + "color_strip.gradient.preset.apply": "Apply", + "color_strip.gradient.preset.aurora": "Aurora", + "color_strip.gradient.preset.cool": "Cool", + "color_strip.gradient.preset.custom": "— Custom —", + "color_strip.gradient.preset.deleted": "Preset deleted", + "color_strip.gradient.preset.fire": "Fire", + "color_strip.gradient.preset.forest": "Forest", + "color_strip.gradient.preset.hint": "Load a predefined gradient palette. Selecting a preset replaces the current stops.", + "color_strip.gradient.preset.ice": "Ice", + "color_strip.gradient.preset.lava": "Lava", + "color_strip.gradient.preset.neon": "Neon", + "color_strip.gradient.preset.ocean": "Ocean", + "color_strip.gradient.preset.pastel": "Pastel", + "color_strip.gradient.preset.rainbow": "Rainbow", + "color_strip.gradient.preset.save_button": "Save as preset…", + "color_strip.gradient.preset.save_prompt": "Enter a name for this preset:", + "color_strip.gradient.preset.saved": "Preset saved", + "color_strip.gradient.preset.sunset": "Sunset", + "color_strip.gradient.preset.warm": "Warm", + "color_strip.gradient.preview": "Gradient:", + "color_strip.gradient.preview.hint": "Visual preview. Click the marker track below to add a stop. Drag markers to reposition.", + "color_strip.gradient.select": "Gradient:", + "color_strip.gradient.select.hint": "Select a gradient from the library. Create and edit gradients in the Gradients tab.", + "color_strip.gradient.stops": "Color Stops:", + "color_strip.gradient.stops.hint": "Each stop defines a color at a relative position (0.0 = start, 1.0 = end). The ↔ button adds a right-side color to create a hard edge at that stop.", + "color_strip.gradient.stops_count": "stops", + "color_strip.interpolation": "Color Mode:", + "color_strip.interpolation.average": "Average", + "color_strip.interpolation.average.desc": "Blend all sampled pixels into a smooth color", + "color_strip.interpolation.dominant": "Dominant", + "color_strip.interpolation.dominant.desc": "Use the most frequent color in the sample", + "color_strip.interpolation.hint": "How to calculate LED color from sampled border pixels", + "color_strip.interpolation.median": "Median", + "color_strip.interpolation.median.desc": "Pick the middle color, reducing outliers", + "color_strip.key_colors.brightness": "Brightness:", + "color_strip.key_colors.configure_regions": "Configure Regions", + "color_strip.key_colors.error.no_rects": "At least one screen region is required", + "color_strip.key_colors.error.no_source": "Picture source is required", + "color_strip.key_colors.interpolation": "Color Mode:", + "color_strip.key_colors.mode.average": "Average", + "color_strip.key_colors.mode.average.desc": "Mean color of all pixels in the region", + "color_strip.key_colors.mode.dominant": "Dominant", + "color_strip.key_colors.mode.dominant.desc": "Most frequent color (K-means clustering)", + "color_strip.key_colors.mode.median": "Median", + "color_strip.key_colors.mode.median.desc": "Median color (less affected by outliers)", + "color_strip.key_colors.no_rects": "No regions defined. Click Configure Regions to add.", + "color_strip.key_colors.picture_source": "Picture Source:", + "color_strip.key_colors.rectangles": "Screen Regions:", + "color_strip.key_colors.smoothing": "Smoothing:", + "color_strip.led_count": "LED Count:", + "color_strip.led_count.hint": "Total number of LEDs on the physical strip. For screen sources: 0 = auto from calibration (extra LEDs not mapped to edges will be black). For single color: set to match your device LED count.", + "color_strip.leds": "LED count", + "color_strip.mapped.add_zone": "+ Add Zone", + "color_strip.mapped.error.no_source": "Each zone must have a source selected", + "color_strip.mapped.select_source": "Search sources...", + "color_strip.mapped.zone_end": "End LED", + "color_strip.mapped.zone_reverse": "Reverse", + "color_strip.mapped.zone_source": "Source", + "color_strip.mapped.zone_start": "Start LED", + "color_strip.mapped.zones": "Zones:", + "color_strip.mapped.zones.hint": "Each zone maps a color strip source to a specific LED range. Zones are placed side-by-side — gaps between zones stay black.", + "color_strip.mapped.zones_count": "zones", + "color_strip.math_wave.add_wave": "+ Add Wave", + "color_strip.math_wave.amplitude": "Amplitude", + "color_strip.math_wave.error.no_waves": "Add at least one wave layer.", + "color_strip.math_wave.frequency": "Frequency", + "color_strip.math_wave.gradient": "Color Gradient:", + "color_strip.math_wave.gradient.hint": "The gradient used to color the wave output. Wave values are mapped to positions along this gradient.", + "color_strip.math_wave.offset": "Offset", + "color_strip.math_wave.phase": "Phase", + "color_strip.math_wave.speed": "Speed:", + "color_strip.math_wave.speed.hint": "Animation speed multiplier. Higher values make the wave move faster.", + "color_strip.math_wave.waveform": "Waveform", + "color_strip.math_wave.waveform.sawtooth": "Sawtooth", + "color_strip.math_wave.waveform.sine": "Sine", + "color_strip.math_wave.waveform.square": "Square", + "color_strip.math_wave.waveform.triangle": "Triangle", + "color_strip.math_wave.waves": "Wave Layers:", + "color_strip.math_wave.waves.hint": "Add multiple wave layers that are combined together. Each wave has its own waveform, frequency, amplitude, phase and offset.", + "color_strip.name": "Name:", + "color_strip.name.placeholder": "Wall Strip", + "color_strip.notification.app_colors": "App Colors", + "color_strip.notification.app_colors.add": "+ Add Mapping", + "color_strip.notification.app_colors.hint": "Per-app color overrides. Each row maps an app name to a specific notification color.", + "color_strip.notification.app_colors.label": "Color Mappings:", + "color_strip.notification.app_count": "apps", + "color_strip.notification.app_overrides": "Per-App Overrides", + "color_strip.notification.app_overrides.add": "+ Add Override", + "color_strip.notification.app_overrides.app_placeholder": "App name", + "color_strip.notification.app_overrides.hint": "Per-app overrides for color and sound. Each row can set a custom color, sound asset, and volume for a specific app.", + "color_strip.notification.app_overrides.label": "App Overrides:", + "color_strip.notification.default_color": "Default Color:", + "color_strip.notification.default_color.hint": "Color used when the notification has no app-specific color mapping.", + "color_strip.notification.duration": "Duration (ms):", + "color_strip.notification.duration.hint": "How long the notification effect plays, in milliseconds.", + "color_strip.notification.effect": "Effect:", + "color_strip.notification.effect.chase": "Chase", + "color_strip.notification.effect.chase.desc": "Light travels across strip and bounces back", + "color_strip.notification.effect.flash": "Flash", + "color_strip.notification.effect.flash.desc": "Instant on, linear fade-out", + "color_strip.notification.effect.gradient_flash": "Gradient Flash", + "color_strip.notification.effect.gradient_flash.desc": "Bright center fades to edges, then all fades out", + "color_strip.notification.effect.hint": "Visual effect when a notification fires. Flash fades linearly, Pulse uses a smooth bell curve, Sweep fills LEDs left-to-right then fades.", + "color_strip.notification.effect.pulse": "Pulse", + "color_strip.notification.effect.pulse.desc": "Smooth bell-curve glow", + "color_strip.notification.effect.sweep": "Sweep", + "color_strip.notification.effect.sweep.desc": "Fills left-to-right then fades", + "color_strip.notification.endpoint": "Webhook Endpoint:", + "color_strip.notification.endpoint.hint": "Use this URL to trigger notifications from external systems. POST with optional JSON body: {\"app\": \"AppName\", \"color\": \"#FF0000\"}.", + "color_strip.notification.filter_list": "App List:", + "color_strip.notification.filter_list.hint": "One app name per line. Use Browse to pick from running processes.", + "color_strip.notification.filter_list.placeholder": "Discord\nSlack\nTelegram", + "color_strip.notification.filter_mode": "App Filter:", + "color_strip.notification.filter_mode.blacklist": "Blacklist", + "color_strip.notification.filter_mode.blacklist.desc": "All except listed apps", + "color_strip.notification.filter_mode.hint": "Filter notifications by app name. Off = accept all, Whitelist = only listed apps, Blacklist = all except listed apps.", + "color_strip.notification.filter_mode.off": "Off", + "color_strip.notification.filter_mode.off.desc": "Accept all notifications", + "color_strip.notification.filter_mode.whitelist": "Whitelist", + "color_strip.notification.filter_mode.whitelist.desc": "Only listed apps", + "color_strip.notification.history.empty": "No notifications captured yet", + "color_strip.notification.history.error": "Failed to load notification history", + "color_strip.notification.history.filtered": "Streams filtered", + "color_strip.notification.history.fired": "Streams triggered", + "color_strip.notification.history.hint": "Recent OS notifications captured by the listener (newest first). Up to 50 entries.", + "color_strip.notification.history.refresh": "Refresh", + "color_strip.notification.history.title": "Notification History", + "color_strip.notification.history.unavailable": "OS notification listener is not available on this platform", + "color_strip.notification.history.unknown_app": "Unknown app", + "color_strip.notification.os_listener": "Listen to OS Notifications:", + "color_strip.notification.os_listener.hint": "When enabled, this source automatically fires when a desktop notification appears (Windows toast / Linux D-Bus). Requires the app to have notification access permission.", + "color_strip.notification.save_first": "Save the source first to see the webhook endpoint URL.", + "color_strip.notification.search_apps": "Search notification apps…", + "color_strip.notification.sound": "Sound", + "color_strip.notification.sound.app_name_placeholder": "App name", + "color_strip.notification.sound.app_sounds": "Per-App Sounds:", + "color_strip.notification.sound.app_sounds.add": "+ Add Override", + "color_strip.notification.sound.app_sounds.hint": "Override sound and volume for specific apps. Empty sound = mute that app.", + "color_strip.notification.sound.asset": "Sound Asset:", + "color_strip.notification.sound.asset.hint": "Pick a sound asset to play when a notification fires. Leave empty for silent.", + "color_strip.notification.sound.none": "None (silent)", + "color_strip.notification.sound.search": "Search sounds…", + "color_strip.notification.sound.volume": "Volume:", + "color_strip.notification.sound.volume.hint": "Global volume for notification sounds (0–100%).", + "color_strip.notification.test": "Test notification", + "color_strip.notification.test.error": "Failed to send notification", + "color_strip.notification.test.no_streams": "No running streams for this source", + "color_strip.notification.test.ok": "Notification sent", + "color_strip.palette.aurora": "Aurora", + "color_strip.palette.custom": "Custom", + "color_strip.palette.fire": "Fire", + "color_strip.palette.forest": "Forest", + "color_strip.palette.ice": "Ice", + "color_strip.palette.lava": "Lava", + "color_strip.palette.ocean": "Ocean", + "color_strip.palette.rainbow": "Rainbow", + "color_strip.palette.sunset": "Sunset", + "color_strip.picture_source": "Picture Source:", + "color_strip.picture_source.hint": "Which screen capture source to use as input for LED color calculation", + "color_strip.preview.connected": "Connected", + "color_strip.preview.connecting": "Connecting...", + "color_strip.preview.not_connected": "Not connected", + "color_strip.preview.save_first": "Save the source first — calibration is needed for preview", + "color_strip.preview.title": "Live Preview", + "color_strip.preview.unsupported": "Preview not available for this source type", + "color_strip.processed.error.no_input": "Please select an input source", + "color_strip.processed.input": "Source:", + "color_strip.processed.input.hint": "The color strip source whose output will be processed", + "color_strip.processed.template": "Processing Template:", + "color_strip.processed.template.hint": "Filter chain to apply to the input source output", + "color_strip.saturation": "Saturation:", + "color_strip.saturation.hint": "Color saturation (0=grayscale, 1=unchanged, 2=double saturation)", + "color_strip.select_type": "Select Color Strip Type", + "color_strip.single_color": "Color:", + "color_strip.single_color.hint": "The solid color that will be sent to all LEDs on the strip.", + "color_strip.smoothing": "Smoothing:", + "color_strip.smoothing.hint": "Temporal blending between frames (0=none, 1=full). Reduces flicker.", + "color_strip.test.apply": "Apply", + "color_strip.test.composite": "Composite", + "color_strip.test.connecting": "Connecting...", + "color_strip.test.error": "Failed to connect to preview stream", + "color_strip.test.fps": "FPS:", + "color_strip.test.led_count": "LEDs:", + "color_strip.test.receive_fps": "Receive FPS", + "color_strip.test.title": "Test Preview", + "color_strip.test_device": "Test on Device:", + "color_strip.test_device.hint": "Select a device to send test pixels to when clicking edge toggles", + "color_strip.type": "Type:", + "color_strip.type.api_input": "API Input", + "color_strip.type.api_input.desc": "Receive colors from external apps", + "color_strip.type.api_input.hint": "Receives raw LED color arrays from external clients via REST POST or WebSocket. Use this to integrate with custom software, home automation, or any system that can send HTTP requests.", + "color_strip.type.audio": "Audio Reactive", + "color_strip.type.audio.desc": "LEDs driven by audio input", + "color_strip.type.audio.hint": "LED colors driven by real-time audio input — system audio or microphone.", + "color_strip.type.candlelight": "Candlelight", + "color_strip.type.candlelight.desc": "Realistic flickering candle simulation", + "color_strip.type.candlelight.hint": "Simulates realistic candle flickering across all LEDs with warm tones and organic flicker patterns.", + "color_strip.type.composite": "Composite", + "color_strip.type.composite.desc": "Stack and blend multiple sources", + "color_strip.type.composite.hint": "Stack multiple color strip sources as layers with blend modes and opacity.", + "color_strip.type.daylight": "Daylight Cycle", + "color_strip.type.daylight.desc": "Simulates natural daylight over 24 hours", + "color_strip.type.daylight.hint": "Simulates the sun\u0027s color temperature throughout a 24-hour day/night cycle — from warm sunrise to cool daylight to warm sunset and dim night.", + "color_strip.type.effect": "Effect", + "color_strip.type.effect.desc": "Procedural effects like fire, plasma, aurora", + "color_strip.type.effect.hint": "Procedural LED effects (fire, meteor, plasma, noise, aurora) generated in real time.", + "color_strip.type.game_event": "Game Event", + "color_strip.type.game_event.desc": "LED effects triggered by game events", + "color_strip.type.gradient": "Gradient", + "color_strip.type.gradient.desc": "Smooth color transition across LEDs", + "color_strip.type.hint": "Picture Source derives LED colors from a screen capture. Single Color fills all LEDs with a single constant color. Gradient distributes a color gradient across all LEDs. Color Cycle smoothly cycles through a user-defined list of colors. Composite stacks multiple sources as blended layers. Audio Reactive drives LEDs from real-time audio input. API Input receives raw LED colors from external clients via REST or WebSocket.", + "color_strip.type.key_colors": "Key Colors", + "color_strip.type.key_colors.desc": "Extract colors from screen regions", + "color_strip.type.mapped": "Mapped", + "color_strip.type.mapped.desc": "Assign sources to LED zones", + "color_strip.type.mapped.hint": "Assign different color strip sources to different LED ranges (zones). Unlike composite which blends layers, mapped places sources side-by-side.", + "color_strip.type.math_wave": "Math Wave", + "color_strip.type.math_wave.desc": "Mathematical wave generator with gradient color mapping", + "color_strip.type.notification": "Notification", + "color_strip.type.notification.desc": "One-shot effect on webhook trigger", + "color_strip.type.notification.hint": "Fires a one-shot visual effect (flash, pulse, sweep) when triggered via a webhook. Designed for use as a composite layer over a persistent base source.", + "color_strip.type.picture": "Picture Source", + "color_strip.type.picture.desc": "Colors from screen capture", + "color_strip.type.picture_advanced": "Multi-Monitor", + "color_strip.type.picture_advanced.desc": "Line-based calibration across monitors", + "color_strip.type.processed": "Processed", + "color_strip.type.processed.desc": "Apply a processing template to another source", + "color_strip.type.processed.hint": "Wraps an existing color strip source and pipes its output through a filter chain.", + "color_strip.type.single_color": "Single Color", + "color_strip.type.single_color.desc": "Single solid color fill", + "color_strip.type.weather": "Weather", + "color_strip.type.weather.desc": "Weather-reactive ambient colors", + "color_strip.type.weather.hint": "Maps real-time weather conditions to ambient LED colors. Requires a Weather Source entity.", + "color_strip.updated": "Color strip source updated", + "color_strip.weather.error.no_source": "Please select a weather source", + "color_strip.weather.source": "Weather Source:", + "color_strip.weather.source.hint": "The weather data source to use for ambient colors. Create one in the Weather tab first.", + "color_strip.weather.speed": "Animation Speed:", + "color_strip.weather.speed.hint": "Speed of the ambient color drift animation. Higher = faster movement.", + "color_strip.weather.temperature_influence": "Temperature Influence:", + "color_strip.weather.temperature_influence.hint": "How much the current temperature shifts the palette warm/cool. 0 = pure condition colors, 1 = strong shift.", + "common.apply": "Apply", + "common.back": "Back", + "common.cancel": "Cancel", + "common.card_color": "Card color", + "common.clone": "Clone", + "common.daylight_tz.detected": "Browser-detected", + "common.daylight_tz.detected_label": "Auto-detected", + "common.daylight_tz.region.africa": "Africa", + "common.daylight_tz.region.americas": "Americas", + "common.daylight_tz.region.asia": "Asia", + "common.daylight_tz.region.europe": "Europe", + "common.daylight_tz.region.me": "Middle East", + "common.daylight_tz.region.pacific": "Pacific", + "common.daylight_tz.region.utc": "Universal", + "common.daylight_tz.search": "Search timezones, cities, regions…", + "common.daylight_tz.system": "System default", + "common.daylight_tz.system_desc": "Server clock — no override", + "common.delete": "Delete", + "common.edit": "Edit", + "common.hide": "Hide card", + "common.led_preview": "LED Preview", + "common.loading": "Loading...", + "common.more_actions": "More actions", + "common.none": "None", + "common.none_no_cspt": "None (no processing template)", + "common.none_no_input": "None (no input source)", + "common.none_own_speed": "None (no sync)", + "common.remove": "Remove", + "common.start": "START", + "common.stop": "STOP", + "common.undo": "Undo", + "confirm.no": "No", + "confirm.stop_all": "Stop all running targets?", + "confirm.title": "Confirm Action", + "confirm.turn_off_device": "Turn off this device?", + "confirm.turn_off_ha_light": "Turn off all mapped lights?", + "confirm.turn_off_z2m_light": "Turn off all mapped bulbs?", + "confirm.yes": "Yes", + "css_processing.add": "Add Strip Processing Template", + "css_processing.created": "Strip processing template created", + "css_processing.delete.confirm": "Are you sure you want to delete this strip processing template?", + "css_processing.deleted": "Strip processing template deleted", + "css_processing.description_label": "Description (optional):", + "css_processing.description_placeholder": "Describe this template...", + "css_processing.edit": "Edit Strip Processing Template", + "css_processing.error.clone_failed": "Failed to clone strip processing template", + "css_processing.error.delete": "Error deleting strip processing template", + "css_processing.error.load": "Error loading strip processing template", + "css_processing.error.required": "Please fill in all required fields", + "css_processing.name": "Template Name:", + "css_processing.name_placeholder": "My Strip Processing Template", + "css_processing.title": "Strip Processing Templates", + "css_processing.updated": "Strip processing template updated", + "dashboard.customize.anim": "Animations", + "dashboard.customize.anim.full": "Full", + "dashboard.customize.anim.off": "Off", + "dashboard.customize.anim.reduced": "Reduced", + "dashboard.customize.cell_drag_help": "Drag a row to change cell order in the Performance strip on the dashboard.", + "dashboard.customize.collapse_default.off": "Start expanded", + "dashboard.customize.collapse_default.on": "Start collapsed", + "dashboard.customize.density.comfortable": "Comfortable", + "dashboard.customize.density.compact": "Compact", + "dashboard.customize.density.dense": "Dense", + "dashboard.customize.drag_help": "Drag rows to reorder, or use the ↑/↓ buttons.", + "dashboard.customize.export": "Export", + "dashboard.customize.exported": "Layout exported", + "dashboard.customize.fixed_top": "Pinned to top", + "dashboard.customize.global": "Global", + "dashboard.customize.hide": "Hide", + "dashboard.customize.import": "Import", + "dashboard.customize.import_failed": "Failed to import layout", + "dashboard.customize.imported": "Layout imported", + "dashboard.customize.mode.app": "App", + "dashboard.customize.mode.both": "Both", + "dashboard.customize.mode.inherit": "Inherit", + "dashboard.customize.mode.system": "Sys", + "dashboard.customize.mode_short": "MODE", + "dashboard.customize.modified": "Modified", + "dashboard.customize.perf_cells": "Performance Strip", + "dashboard.customize.perf_mode": "Perf mode", + "dashboard.customize.preset.diagnostics": "Diagnostics", + "dashboard.customize.preset.operator": "Operator", + "dashboard.customize.preset.showrunner": "Showrunner", + "dashboard.customize.preset.studio": "Studio", + "dashboard.customize.preset.tv": "TV", + "dashboard.customize.presets": "Presets", + "dashboard.customize.reset": "Reset", + "dashboard.customize.reset_confirm": "Reset dashboard layout to the Studio preset?", + "dashboard.customize.scale": "Y-axis scale", + "dashboard.customize.scale_short": "SCL", + "dashboard.customize.sections": "Sections", + "dashboard.customize.show": "Show", + "dashboard.customize.title": "Customize Dashboard", + "dashboard.customize.width": "Width", + "dashboard.customize.width.centered": "Centered", + "dashboard.customize.width.full": "Full", + "dashboard.customize.width.narrow": "Narrow", + "dashboard.customize.window": "Sample window", + "dashboard.customize.window_short": "WIN", + "dashboard.customize.yscale.auto": "Auto", + "dashboard.customize.yscale.fixed": "Fixed", + "dashboard.customize.yscale.log": "Log", + "dashboard.device": "Device", + "dashboard.device.chip": "Chip:", + "dashboard.error.automation_toggle_failed": "Failed to toggle automation", + "dashboard.error.start_failed": "Failed to start processing", + "dashboard.error.stop_all": "Failed to stop all targets", + "dashboard.error.stop_failed": "Failed to stop processing", + "dashboard.errors": "Errors", + "dashboard.failed": "Failed to load dashboard", + "dashboard.fps": "FPS", + "dashboard.integrations.entities": "entities", + "dashboard.integrations.no_sources": "No integration sources configured", + "dashboard.no_targets": "No targets configured", + "dashboard.perf.active_patches": "Active Patches", + "dashboard.perf.color": "Chart color", + "dashboard.perf.cpu": "CPU", + "dashboard.perf.device_latency": "Device Latency", + "dashboard.perf.devices": "Devices", + "dashboard.perf.errors": "Errors", + "dashboard.perf.gpu": "GPU", + "dashboard.perf.mode.app": "App", + "dashboard.perf.mode.both": "Both", + "dashboard.perf.mode.system": "System", + "dashboard.perf.network": "Network", + "dashboard.perf.patches.empty.idle": "Ready to launch", + "dashboard.perf.patches.empty.none": "No patches yet", + "dashboard.perf.ram": "RAM", + "dashboard.perf.send_timing": "Send Timing", + "dashboard.perf.temp": "Temperature", + "dashboard.perf.temp.install_lhm": "Windows has no built-in CPU temperature API. Install LibreHardwareMonitor and enable \"Publish to WMI\" to see live readings here.", + "dashboard.perf.total_capture_fps": "Total Source FPS", + "dashboard.perf.total_capture_fps_actual": "Total Capture FPS", + "dashboard.perf.total_fps": "Total FPS", + "dashboard.perf.unavailable": "unavailable", + "dashboard.poll_interval": "Refresh interval", + "dashboard.recent_activity.view_all": "View all", + "dashboard.section.automations": "Automations", + "dashboard.section.integrations": "Integrations", + "dashboard.section.performance": "System Performance", + "dashboard.section.playlists": "Playlists", + "dashboard.section.recent_activity": "Recent Activity", + "dashboard.section.running": "Running", + "dashboard.section.scenes": "Scene Presets", + "dashboard.section.stopped": "Stopped", + "dashboard.section.sync_clocks": "Sync Clocks", + "dashboard.section.targets": "Channels", + "dashboard.stop_all": "Stop All", + "dashboard.targets": "Targets", + "dashboard.title": "Dashboard", + "dashboard.type.kc": "Key Colors", + "dashboard.type.led": "LED", + "dashboard.uptime": "Uptime", + "demo.badge": "DEMO", + "demo.banner": "You\u0027re in demo mode — all devices and data are virtual. No real hardware is used.", + "device.added": "Device added successfully", + "device.baud_rate": "Baud Rate:", + "device.baud_rate.hint": "Serial communication speed. Higher = more FPS but requires matching Arduino sketch.", + "device.ble.family": "Protocol Family:", + "device.ble.family.govee.desc": "Govee H6xxx strips — unencrypted firmware only", + "device.ble.family.hint": "Which BLE protocol your controller speaks. Match the phone app you normally use.", + "device.ble.family.sp110e.desc": "Addressable controllers — LED Hue / SP110E app", + "device.ble.family.triones.desc": "Single-color controllers — HappyLighting / LEDnet app", + "device.ble.family.zengge.desc": "Single-color controllers — iLightsIn / Mohuan app", + "device.ble.govee_key": "Govee AES Key (hex):", + "device.ble.govee_key.hint": "Optional. Newer Govee firmware needs a per-model AES key — leave blank for older firmware.", + "device.ble.govee_key.placeholder": "32 hex digits, e.g. 0102…1f20", + "device.ble.url": "BLE Address:", + "device.ble.url.hint": "MAC address (Windows/Linux) or UUID (macOS), prefixed with ble://", + "device.brightness": "Bright", + "device.button.add": "Add Device", + "device.button.calibrate": "Calibrate", + "device.button.capture_settings": "Capture Settings", + "device.button.ping": "Ping Device", + "device.button.power_off": "Turn Off", + "device.button.remove": "Remove", + "device.button.settings": "General Settings", + "device.button.start": "Start", + "device.button.stop": "Stop", + "device.button.stream_selector": "Source Settings", + "device.button.webui": "Open Device Web UI", + "device.chroma.device_type": "Peripheral Type:", + "device.chroma.device_type.hint": "Which Razer peripheral to control via Chroma SDK", + "device.css_processing_template": "Strip Processing Template:", + "device.css_processing_template.hint": "Default processing template applied to all color strip outputs on this device", + "device.ddp.color_order.bgr.desc": "BGR byte order", + "device.ddp.color_order.brg.desc": "BRG byte order", + "device.ddp.color_order.gbr.desc": "GBR byte order", + "device.ddp.color_order.grb.desc": "WS2812/WS2812B native order", + "device.ddp.color_order.rbg.desc": "RBG byte order", + "device.ddp.color_order.rgb.desc": "Standard RGB byte order", + "device.ddp.url": "IP Address:", + "device.ddp.url.hint": "DDP receiver address. Port defaults to 4048.", + "device.ddp.url.placeholder": "192.168.1.50", + "device.ddp_color_order": "Color Order:", + "device.ddp_color_order.hint": "Channel byte order on the wire. Most DDP receivers expect RGB.", + "device.ddp_destination_id": "Destination ID:", + "device.ddp_destination_id.hint": "DDP destination identifier (1 = display).", + "device.ddp_port": "DDP Port:", + "device.ddp_port.hint": "UDP port (0 = protocol default 4048).", + "device.display": "Display:", + "device.dmx.url": "IP Address:", + "device.dmx.url.hint": "IP address of the DMX node (e.g. 192.168.1.50)", + "device.dmx.url.placeholder": "192.168.1.50", + "device.dmx_protocol": "DMX Protocol:", + "device.dmx_protocol.artnet.desc": "UDP unicast, port 6454", + "device.dmx_protocol.hint": "Art-Net uses UDP port 6454, sACN (E1.31) uses UDP port 5568", + "device.dmx_protocol.sacn.desc": "Multicast/unicast, port 5568", + "device.dmx_start_channel": "Start Channel:", + "device.dmx_start_channel.hint": "First DMX channel within the universe (1-512)", + "device.dmx_start_universe": "Start Universe:", + "device.dmx_start_universe.hint": "First DMX universe (0-32767). Multiple universes are used automatically for \u003e170 LEDs.", + "device.error.brightness": "Failed to update brightness", + "device.error.clone_failed": "Failed to clone device", + "device.error.load_failed": "Failed to load device", + "device.error.power_off_failed": "Failed to turn off device", + "device.error.remove_failed": "Failed to remove device", + "device.error.required": "Please fill in all fields correctly", + "device.error.save": "Failed to save settings", + "device.error.settings_load_failed": "Failed to load device settings", + "device.error.update": "Failed to update device", + "device.espnow.channel": "WiFi Channel:", + "device.espnow.channel.hint": "WiFi channel (1-14). Must match the receiver\u0027s channel.", + "device.espnow.peer_mac": "Peer MAC:", + "device.espnow.peer_mac.hint": "MAC address of the remote ESP32 receiver (e.g. AA:BB:CC:DD:EE:FF)", + "device.fps": "FPS:", + "device.gamesense.device_type": "Peripheral Type:", + "device.gamesense.device_type.hint": "Which SteelSeries peripheral to control via GameSense", + "device.gamesense.peripheral.headset": "Headset", + "device.gamesense.peripheral.headset.desc": "Headset earcup lighting", + "device.gamesense.peripheral.indicator": "Indicator", + "device.gamesense.peripheral.indicator.desc": "OLED/LED status indicator", + "device.gamesense.peripheral.keyboard": "Keyboard", + "device.gamesense.peripheral.keyboard.desc": "Per-key RGB illumination", + "device.gamesense.peripheral.mouse": "Mouse", + "device.gamesense.peripheral.mouse.desc": "Mouse RGB zones", + "device.gamesense.peripheral.mousepad": "Mousepad", + "device.gamesense.peripheral.mousepad.desc": "Mousepad edge lighting zones", + "device.govee.url": "IP Address:", + "device.govee.url.hint": "LAN IP of the Govee device. Enable LAN Control in the Govee Home app first (Device → ⚙ → LAN Control), or the bulb won’t respond.", + "device.govee.url.placeholder": "192.168.1.50", + "device.govee_min_interval": "Min Update Interval:", + "device.govee_min_interval.hint": "Client-side rate limit between commands in ms. UDP fire-and-forget tolerates fast updates; default 50 ms ≈ 20 Hz.", + "device.group.add_child": "+ Add Device", + "device.group.children": "Child Devices:", + "device.group.children.hint": "Select devices to include in this group. Order matters for sequence mode.", + "device.group.error.no_children": "Add at least one child device to the group.", + "device.group.mode": "Group Mode:", + "device.group.mode.hint": "Sequence concatenates LEDs end-to-end. Independent mirrors the full strip to each device.", + "device.group.mode.independent": "Independent", + "device.group.mode.independent.desc": "Mirror the full strip to each device independently", + "device.group.mode.sequence": "Sequence", + "device.group.mode.sequence.desc": "Concatenate LEDs end-to-end into one long strip", + "device.group.move_down": "Move down", + "device.group.move_up": "Move up", + "device.group.select_device": "Select a device", + "device.health.checking": "Checking...", + "device.health.offline": "Offline", + "device.health.online": "Online", + "device.health.streaming_unreachable": "Unreachable during streaming", + "device.hue.client_key": "Client Key:", + "device.hue.client_key.hint": "Entertainment API client key (hex string from pairing)", + "device.hue.group_id": "Entertainment Group:", + "device.hue.group_id.hint": "Entertainment configuration ID from your Hue bridge", + "device.hue.url": "Bridge IP:", + "device.hue.url.hint": "IP address of your Hue bridge", + "device.hue.username": "Bridge Username:", + "device.hue.username.hint": "Hue bridge application key from pairing", + "device.icon.add": "Add", + "device.icon.audio_file": "Audio file", + "device.icon.bath": "Bathroom", + "device.icon.battery": "Battery", + "device.icon.bot": "Bot", + "device.icon.briefcase": "Briefcase", + "device.icon.bulb_off": "Bulb off", + "device.icon.calendar": "Calendar", + "device.icon.camcorder": "Camcorder", + "device.icon.candle": "Candle", + "device.icon.cat.all": "All", + "device.icon.cat.ambience": "Ambience", + "device.icon.cat.controls": "Controls", + "device.icon.cat.hardware": "Hardware", + "device.icon.cat.lighting": "Lighting", + "device.icon.cat.media": "Media", + "device.icon.cat.nature": "Nature", + "device.icon.cat.office": "Office", + "device.icon.cat.rooms": "Rooms", + "device.icon.cat.signal": "Signal", + "device.icon.cat.status": "Status", + "device.icon.cat.weather": "Weather", + "device.icon.ceiling": "Ceiling light", + "device.icon.change": "Change icon…", + "device.icon.check": "Check", + "device.icon.checklist": "Checklist", + "device.icon.choose": "Choose icon…", + "device.icon.clock": "Clock", + "device.icon.code": "Code", + "device.icon.coffee": "Coffee", + "device.icon.color_toggle": "Color", + "device.icon.desk_lamp": "Desk lamp", + "device.icon.dim": "Dim light", + "device.icon.disc": "Disc", + "device.icon.doc": "Document", + "device.icon.empty": "No icons match.", + "device.icon.entity.asset": "Asset", + "device.icon.entity.audio_processing_template": "Audio processing template", + "device.icon.entity.audio_source": "Audio source", + "device.icon.entity.audio_template": "Audio template", + "device.icon.entity.automation": "Automation", + "device.icon.entity.capture_template": "Capture template", + "device.icon.entity.color_strip_source": "Color strip", + "device.icon.entity.cspt": "Color-strip processing template", + "device.icon.entity.device": "Device", + "device.icon.entity.game_integration": "Game integration", + "device.icon.entity.gradient": "Gradient", + "device.icon.entity.ha_light_target": "HA light target", + "device.icon.entity.ha_source": "Home Assistant source", + "device.icon.entity.http_endpoint": "HTTP endpoint", + "device.icon.entity.mqtt_source": "MQTT source", + "device.icon.entity.pattern_template": "Pattern template", + "device.icon.entity.picture_source": "Picture source", + "device.icon.entity.pp_template": "Post-processing template", + "device.icon.entity.scene_playlist": "Playlist", + "device.icon.entity.scene_preset": "Scene preset", + "device.icon.entity.sync_clock": "Sync clock", + "device.icon.entity.target": "LED target", + "device.icon.entity.value_source": "Value source", + "device.icon.entity.weather_source": "Weather source", + "device.icon.error.save_failed": "Failed to save icon", + "device.icon.eyebrow": "Card icon", + "device.icon.flashlight": "Flashlight", + "device.icon.flower": "Flower", + "device.icon.fog": "Fog", + "device.icon.for": "for", + "device.icon.garage": "Garage", + "device.icon.hashtag": "Hashtag", + "device.icon.help": "Help", + "device.icon.hint": "↵ Apply · Esc Cancel", + "device.icon.image": "Image", + "device.icon.inherited_from": "Inherited from %s", + "device.icon.key": "Key", + "device.icon.kitchen": "Kitchen", + "device.icon.laptop": "Laptop", + "device.icon.leaf": "Leaf", + "device.icon.link": "Link", + "device.icon.lock": "Lock", + "device.icon.mail": "Mail", + "device.icon.mcu": "Microcontroller", + "device.icon.mountain": "Mountain", + "device.icon.off": "Disabled", + "device.icon.ok": "OK", + "device.icon.outdoor": "Outdoor", + "device.icon.override_inherited": "Override inherited icon…", + "device.icon.package": "Device unit", + "device.icon.phone": "Phone", + "device.icon.point": "Point light", + "device.icon.projector": "Projector", + "device.icon.pulse": "Pulse", + "device.icon.rain": "Rain", + "device.icon.recent": "Recent", + "device.icon.refresh": "Refresh", + "device.icon.remove": "Remove icon", + "device.icon.ring": "Ring light", + "device.icon.router": "Router", + "device.icon.saved": "Icon saved", + "device.icon.search": "Search", + "device.icon.search.placeholder": "Search icons…", + "device.icon.send": "Send", + "device.icon.server": "Server", + "device.icon.settings": "Settings", + "device.icon.shield": "Shield", + "device.icon.show": "Show", + "device.icon.slider": "Slider", + "device.icon.snow": "Snow", + "device.icon.snowflake": "Snowflake", + "device.icon.sprout": "Sprout", + "device.icon.sunrise": "Sunrise", + "device.icon.sunset": "Sunset", + "device.icon.switch": "Switch", + "device.icon.target": "Target", + "device.icon.thunder": "Thunder", + "device.icon.title": "Choose an icon", + "device.icon.tool": "Tool", + "device.icon.trash": "Trash", + "device.icon.tree": "Tree", + "device.icon.trend": "Trending", + "device.icon.umbrella": "Umbrella", + "device.icon.undo": "Undo", + "device.icon.use_inherited": "Use inherited", + "device.icon.wall_light": "Wall light", + "device.icon.warning": "Warning", + "device.icon.watch": "Smartwatch", + "device.icon.water_drops": "Water drops", + "device.icon.waves": "Waves", + "device.icon.webcam": "Webcam", + "device.icon.wind": "Wind", + "device.last_seen.days": "%dd ago", + "device.last_seen.hours": "%dh ago", + "device.last_seen.just_now": "just now", + "device.last_seen.label": "Last seen", + "device.last_seen.minutes": "%dm ago", + "device.last_seen.seconds": "%ds ago", + "device.led_count": "LED Count:", + "device.led_count.hint": "Number of LEDs configured in the device", + "device.led_count.hint.auto": "Auto-detected from device", + "device.led_count_manual.hint": "Number of LEDs on the strip (must match your Arduino sketch)", + "device.led_type": "LED Type:", + "device.led_type.hint": "RGB (3 channels) or RGBW (4 channels with dedicated white)", + "device.lifx.url": "IP Address:", + "device.lifx.url.hint": "LAN IP of the LIFX bulb. UDP port 56700 is the protocol default.", + "device.lifx.url.placeholder": "192.168.1.50", + "device.lifx_min_interval": "Min Update Interval:", + "device.lifx_min_interval.hint": "Client-side rate limit between commands in ms. LIFX recommends ≤20 cmd/sec; default 50 ms matches that ceiling.", + "device.metrics.actual_fps": "Actual FPS", + "device.metrics.current_fps": "Current FPS", + "device.metrics.device_fps": "Device refresh rate", + "device.metrics.errors": "Errors", + "device.metrics.frames": "Frames", + "device.metrics.frames_skipped": "Skipped", + "device.metrics.keepalive": "Keepalive", + "device.metrics.potential_fps": "Potential FPS", + "device.metrics.target_fps": "Target FPS", + "device.metrics.timing": "Pipeline timing:", + "device.metrics.uptime": "Uptime", + "device.mqtt_source": "MQTT Broker:", + "device.mqtt_source.hint": "Which MQTT broker this device publishes to. Manage brokers under Integrations → MQTT Sources. Leave unset to use the first available broker.", + "device.mqtt_source.none": "— First available broker", + "device.mqtt_topic": "MQTT Topic:", + "device.mqtt_topic.hint": "MQTT topic path for publishing pixel data (e.g. mqtt://ledgrab/device/name)", + "device.mqtt_topic.placeholder": "mqtt://ledgrab/device/living-room", + "device.name": "Device Name:", + "device.name.placeholder": "Living Room TV", + "device.nanoleaf.pair.instructions": "Press and hold the power button on your Nanoleaf controller for 5 seconds until the LEDs flash, then click Start. The controller opens a 30-second pairing window.", + "device.nanoleaf.pair_button": "Pair Device", + "device.nanoleaf.paired": "Paired", + "device.nanoleaf.token.label": "Auth token:", + "device.nanoleaf.url": "IP Address:", + "device.nanoleaf.url.hint": "LAN IP of the Nanoleaf controller. HTTP port 16021 is fixed in the protocol.", + "device.nanoleaf.url.placeholder": "192.168.1.50", + "device.nanoleaf_min_interval": "Min Update Interval:", + "device.nanoleaf_min_interval.hint": "Client-side rate limit between commands in ms. Default 100 ms ≈ 10 Hz; HTTP request overhead caps the practical max around 20 Hz.", + "device.opc.url": "IP Address:", + "device.opc.url.hint": "OPC receiver address. TCP port defaults to 7890.", + "device.opc.url.placeholder": "192.168.1.50", + "device.opc_channel": "Channel:", + "device.opc_channel.hint": "OPC channel (0 = broadcast to every channel on the server, 1-255 = specific output).", + "device.openrgb.added_multiple": "Added {count} devices", + "device.openrgb.mode": "Zone mode:", + "device.openrgb.mode.combined": "Combined strip", + "device.openrgb.mode.hint": "Combined treats all zones as one continuous LED strip. Separate renders each zone independently with the full effect.", + "device.openrgb.mode.separate": "Independent zones", + "device.openrgb.url": "OpenRGB URL:", + "device.openrgb.url.hint": "OpenRGB server address (e.g. openrgb://localhost:6742/0)", + "device.openrgb.zone": "Zones:", + "device.openrgb.zone.error": "Failed to load zones", + "device.openrgb.zone.hint": "Select which LED zones to control (leave all unchecked for all zones)", + "device.openrgb.zone.loading": "Loading zones…", + "device.ping.error": "Ping failed", + "device.ping.offline": "Device offline", + "device.ping.online": "Online ({ms}ms)", + "device.power.off_success": "Device turned off", + "device.remove.confirm": "Are you sure you want to remove this device?", + "device.removed": "Device removed", + "device.scan": "Auto Discovery", + "device.scan.already_added": "Already added", + "device.scan.empty": "No devices found", + "device.scan.error": "Network scan failed", + "device.scan.selected": "Device selected", + "device.select_type": "Select Device Type", + "device.send_latency": "Send Latency (ms):", + "device.send_latency.hint": "Simulated network/serial delay per frame in milliseconds", + "device.serial_port": "Serial Port:", + "device.serial_port.hint": "Select the COM port of the Adalight device", + "device.serial_port.none": "No serial ports found", + "device.serial_port.select": "Select a port...", + "device.spi.led_type": "LED Chipset:", + "device.spi.led_type.hint": "Type of addressable LED strip connected to the GPIO/SPI pin", + "device.spi.led_type.sk6812.desc": "Samsung LED, 800 KHz, 3-wire RGB", + "device.spi.led_type.sk6812_rgbw.desc": "SK6812 with dedicated white channel", + "device.spi.led_type.ws2811.desc": "External driver IC, 400 KHz, 12V strips", + "device.spi.led_type.ws2812.desc": "Original WS2812, 800 KHz, 3-wire RGB", + "device.spi.led_type.ws2812b.desc": "Most common, 800 KHz data, 3-wire RGB", + "device.spi.speed": "SPI Speed (Hz):", + "device.spi.speed.hint": "SPI clock speed. 800000 Hz for WS2812, 2400000 Hz for APA102.", + "device.spi.url": "GPIO/SPI Path:", + "device.spi.url.hint": "GPIO pin or SPI device path (e.g. spi://gpio:18)", + "device.started": "Processing started", + "device.status.connected": "Connected", + "device.status.disconnected": "Disconnected", + "device.status.error": "Error", + "device.status.idle": "Idle", + "device.status.processing": "Processing", + "device.stopped": "Processing stopped", + "device.stream_selector.hint": "Select a source that defines what this device captures and processes", + "device.stream_selector.label": "Source:", + "device.stream_selector.none": "-- No source assigned --", + "device.stream_selector.saved": "Source settings updated", + "device.stream_settings.border_width": "Border Width (px):", + "device.stream_settings.border_width_hint": "How many pixels from the screen edge to sample for LED colors (1-100)", + "device.stream_settings.interpolation": "Interpolation Mode:", + "device.stream_settings.interpolation.average": "Average", + "device.stream_settings.interpolation.dominant": "Dominant", + "device.stream_settings.interpolation.median": "Median", + "device.stream_settings.interpolation_hint": "How to calculate LED color from sampled pixels", + "device.stream_settings.smoothing": "Smoothing:", + "device.stream_settings.smoothing_hint": "Temporal blending between frames (0=none, 1=full). Reduces flicker.", + "device.stream_settings.title": "Source Settings", + "device.tip.add": "Click here to add a new LED device", + "device.tip.brightness": "Drag to adjust device brightness. Changes are sent to the device immediately.", + "device.tip.identity": "Device identity — name, type badge, and health indicator showing online/offline status.", + "device.tip.menu": "More actions — duplicate, hide, or delete this device.", + "device.tip.metadata": "Device address and firmware version. Click the URL to open the device\u0027s built-in web UI.", + "device.tip.ping": "Ping the device to refresh online status and latency.", + "device.tip.settings": "Open device settings — configure name, URL, capabilities, and health checks.", + "device.tip.stream_selector": "Configure picture source and LED projection settings for this device", + "device.tutorial.start": "Start tutorial", + "device.type": "Device Type:", + "device.type.adalight": "Adalight", + "device.type.adalight.desc": "Serial LED protocol for Arduino", + "device.type.ambiled": "AmbiLED", + "device.type.ambiled.desc": "Serial protocol for AmbiLED devices", + "device.type.ble": "BLE LED Controller", + "device.type.ble.desc": "Bluetooth LE strips: SP110E, Triones, Zengge, Govee (whole-strip color)", + "device.type.chroma": "Razer Chroma", + "device.type.chroma.desc": "Razer peripherals via Chroma SDK", + "device.type.ddp": "DDP", + "device.type.ddp.desc": "Direct UDP pixel push (Pixelblaze, ESPixelStick, Falcon)", + "device.type.dmx": "DMX", + "device.type.dmx.desc": "Art-Net / sACN (E1.31) stage lighting", + "device.type.espnow": "ESP-NOW", + "device.type.espnow.desc": "Ultra-low-latency via ESP32 gateway", + "device.type.gamesense": "SteelSeries", + "device.type.gamesense.desc": "SteelSeries peripherals via GameSense", + "device.type.govee": "Govee", + "device.type.govee.desc": "Govee Wi-Fi bulb / ambient kit via LAN API", + "device.type.group": "Group", + "device.type.group.desc": "Combine multiple devices into one virtual device", + "device.type.hint": "Select the type of LED controller", + "device.type.hue": "Philips Hue", + "device.type.hue.desc": "Hue Entertainment API streaming", + "device.type.lifx": "LIFX", + "device.type.lifx.desc": "LIFX smart bulb / lightstrip over LAN", + "device.type.mock": "Mock", + "device.type.mock.desc": "Virtual device for testing", + "device.type.mqtt": "MQTT", + "device.type.mqtt.desc": "Publish LED data via MQTT broker", + "device.type.nanoleaf": "Nanoleaf", + "device.type.nanoleaf.desc": "Nanoleaf Light Panels / Canvas / Shapes / Lines / Elements (HTTP REST, requires pairing)", + "device.type.opc": "OPC", + "device.type.opc.desc": "Open Pixel Control (Fadecandy, xLights, hobbyist drivers)", + "device.type.openrgb": "OpenRGB", + "device.type.openrgb.desc": "Control RGB peripherals via OpenRGB", + "device.type.spi": "SPI Direct", + "device.type.spi.desc": "Raspberry Pi GPIO/SPI LED strips", + "device.type.usbhid": "USB HID", + "device.type.usbhid.desc": "USB RGB peripherals (keyboards, mice)", + "device.type.wiz": "WiZ", + "device.type.wiz.desc": "WiZ Connected (Philips) UDP LAN bulb", + "device.type.wled": "WLED", + "device.type.wled.desc": "WiFi LED controller over HTTP/UDP", + "device.type.ws": "WebSocket", + "device.type.ws.desc": "Stream LED data to WebSocket clients", + "device.type.yeelight": "Yeelight", + "device.type.yeelight.desc": "Xiaomi smart bulb / lightstrip via LAN (single color, averaged from the strip)", + "device.url": "URL:", + "device.url.hint": "IP address or hostname of the device (e.g. http://192.168.1.100)", + "device.url.placeholder": "http://192.168.1.100", + "device.usbhid.url": "VID:PID:", + "device.usbhid.url.hint": "USB Vendor:Product ID in hex (e.g. 1532:0084)", + "device.wiz.url": "IP Address:", + "device.wiz.url.hint": "LAN IP of the WiZ bulb. UDP port 38899 is the protocol default.", + "device.wiz.url.placeholder": "192.168.1.50", + "device.wiz_min_interval": "Min Update Interval:", + "device.wiz_min_interval.hint": "Client-side rate limit between commands in ms. UDP fire-and-forget tolerates fast updates; default 50 ms ≈ 20 Hz.", + "device.ws_url": "Connection URL:", + "device.ws_url.hint": "WebSocket URL for clients to connect and receive LED data", + "device.yeelight.url": "IP Address:", + "device.yeelight.url.hint": "LAN IP of the Yeelight bulb. TCP port 55443 is fixed in the protocol.", + "device.yeelight.url.placeholder": "192.168.1.50", + "device.yeelight_min_interval": "Min Update Interval:", + "device.yeelight_min_interval.hint": "Client-side rate limit between commands in ms. Default 500 ms keeps bulbs under their ~1 cmd/sec cap; lower values risk throttling.", + "device_discovery.added": "Device added successfully", + "device_discovery.error.add_failed": "Failed to add device", + "device_discovery.error.fill_all_fields": "Please fill in all fields", + "devices.add": "Add New Device", + "devices.failed": "Failed to load devices", + "devices.loading": "Loading devices...", + "devices.none": "No devices configured", + "devices.title": "Devices", + "devices.wled_config": "WLED Configuration:", + "devices.wled_link": "official WLED app", + "devices.wled_note": "Configure your WLED device (effects, segments, color order, power limits, etc.) using the", + "devices.wled_note_or": "or the built-in", + "devices.wled_note_webui": "(open your device\u0027s IP in a browser).", + "devices.wled_note2": "This controller sends pixel color data and controls brightness per device.", + "devices.wled_webui_link": "WLED Web UI", + "displays.badge.primary": "Primary", + "displays.badge.secondary": "Secondary", + "displays.failed": "Failed to load displays", + "displays.index": "Display Index:", + "displays.information": "Display Information", + "displays.layout": "Displays", + "displays.legend.primary": "Primary Display", + "displays.legend.secondary": "Secondary Display", + "displays.loading": "Loading displays...", + "displays.none": "No displays available", + "displays.picker.adb_connect": "Connect ADB device", + "displays.picker.adb_connect.button": "Connect", + "displays.picker.adb_connect.error": "Failed to connect device", + "displays.picker.adb_connect.placeholder": "IP address (e.g. 192.168.2.201)", + "displays.picker.adb_connect.success": "Device connected", + "displays.picker.adb_disconnect": "Disconnect", + "displays.picker.click_to_select": "Click to select this display", + "displays.picker.eyebrow.channel": "Display · Map", + "displays.picker.eyebrow.channel.device": "Device · List", + "displays.picker.eyebrow.label": "Source", + "displays.picker.foot.dismiss": "dismiss", + "displays.picker.foot.select": "select monitor", + "displays.picker.foot.select.device": "select device", + "displays.picker.no_android": "No Android devices found. Connect via USB or enter IP above.", + "displays.picker.select": "Select display...", + "displays.picker.title": "Select a Display", + "displays.picker.title.accent": "Display", + "displays.picker.title.lead": "Select", + "displays.position": "Position:", + "displays.refresh_rate": "Refresh Rate:", + "displays.resolution": "Resolution:", + "displays.title": "Available Displays", + "donation.about": "About", + "donation.about_author": "Created by", + "donation.about_donate": "Support development", + "donation.about_license": "MIT License", + "donation.about_opensource": "LedGrab is open-source software, free to use and modify.", + "donation.about_title": "About LedGrab", + "donation.dismiss": "Don\u0027t show again", + "donation.later": "Remind me later", + "donation.message": "LedGrab is free \u0026 open-source. If it\u0027s useful to you, consider supporting development.", + "donation.support": "Support the project", + "donation.view_source": "View source code", + "error.network": "Network error", + "error.unauthorized": "Unauthorized - please login", + "error.unknown": "An error occurred", + "filters.add": "Add Filter", + "filters.audio_filter_template.desc": "Embed another audio processing template", + "filters.auto_crop": "Auto Crop", + "filters.auto_crop.desc": "Remove black bars from letterboxed content", + "filters.auto_gain.desc": "Auto-normalize audio levels to use full range", + "filters.band_extract.desc": "Filter to bass, mid, treble, or custom frequency range", + "filters.beat_gate.desc": "Pass signal only around detected beats", + "filters.brightness": "Brightness", + "filters.brightness.desc": "Adjust overall image brightness", + "filters.channel_extract.desc": "Select mono, left, or right channel", + "filters.color_correction": "Color Correction", + "filters.color_correction.desc": "White balance and color temperature", + "filters.compressor.desc": "Reduce dynamic range above threshold", + "filters.contrast": "Contrast", + "filters.contrast.desc": "Adjust image contrast around mid-gray", + "filters.css_filter_template": "Strip Filter Template", + "filters.css_filter_template.desc": "Embed another strip processing template", + "filters.delay.desc": "Time-shift the audio analysis by a delay", + "filters.downscaler": "Downscaler", + "filters.downscaler.desc": "Reduce resolution for faster processing", + "filters.drag_to_reorder": "Drag to reorder", + "filters.empty": "No filters added. Use the selector below to add filters.", + "filters.envelope_follower.desc": "Smooth amplitude with attack/release times", + "filters.filter_template": "Filter Template", + "filters.filter_template.desc": "Embed another processing template", + "filters.flip": "Flip", + "filters.flip.desc": "Mirror image horizontally or vertically", + "filters.frame_interpolation": "Frame Interpolation", + "filters.frame_interpolation.desc": "Blend between frames for smoother output", + "filters.gain.desc": "Amplify or attenuate signal levels", + "filters.gamma": "Gamma", + "filters.gamma.desc": "Non-linear brightness curve correction", + "filters.hsl_shift": "HSL Shift", + "filters.hsl_shift.desc": "Shift hue, saturation, and lightness values", + "filters.inverter.desc": "Invert all levels (1 minus value)", + "filters.noise_gate": "Noise Gate", + "filters.noise_gate.desc": "Suppress small color changes below threshold", + "filters.palette_quantization": "Palette Quantization", + "filters.palette_quantization.desc": "Reduce colors to a limited palette", + "filters.peak_hold.desc": "Retain peak values with configurable decay", + "filters.pixelate": "Pixelate", + "filters.pixelate.desc": "Mosaic-style block averaging", + "filters.remove": "Remove", + "filters.reverse": "Reverse", + "filters.reverse.desc": "Reverse the LED order in the strip", + "filters.saturation": "Saturation", + "filters.saturation.desc": "Boost or reduce color intensity", + "filters.select_type": "Select filter type...", + "filters.spectral_smoothing.desc": "Extra smoothing on spectrum data over time", + "filters.temporal_blur": "Temporal Blur", + "filters.temporal_blur.desc": "Smooth color transitions over time", + "game_integration.adapter": "Adapter", + "game_integration.adapter_config": "Adapter Configuration", + "game_integration.adapter_type": "Game / Adapter:", + "game_integration.adapter_type.hint": "Select the game or adapter type for this integration", + "game_integration.add": "Add Game Integration", + "game_integration.auto_setup": "Auto Setup", + "game_integration.auto_setup.failed": "Auto setup failed", + "game_integration.auto_setup.game_not_found": "Game installation not found", + "game_integration.auto_setup.not_supported": "This adapter does not support auto setup", + "game_integration.auto_setup.save_first": "Save the integration first before running auto setup", + "game_integration.auto_setup.success": "Configuration file written successfully", + "game_integration.auto_setup.token_generated": "Auth token was automatically generated", + "game_integration.confirm_delete": "Delete this game integration?", + "game_integration.created": "Game integration created", + "game_integration.deleted": "Game integration deleted", + "game_integration.description": "Description:", + "game_integration.description.hint": "Optional description of what this integration does", + "game_integration.edit": "Edit Game Integration", + "game_integration.enabled": "Enabled", + "game_integration.enabled.hint": "Disabled integrations stop polling and emitting events", + "game_integration.error.delete_failed": "Failed to delete game integration", + "game_integration.error.name_required": "Name is required", + "game_integration.error.save_failed": "Failed to save game integration", + "game_integration.error.save_first": "Save the integration first to test the connection", + "game_integration.event_mappings": "Event Mappings", + "game_integration.event_mappings.hint": "Map game events to LED effects. Each event type can trigger a different visual effect.", + "game_integration.events.monitor": "Event Monitor", + "game_integration.events.title": "Live Events", + "game_integration.events.waiting": "Waiting for events...", + "game_integration.mapping.add": "+ Add Mapping", + "game_integration.mapping.color": "Color", + "game_integration.mapping.duration": "Duration (ms)", + "game_integration.mapping.effect_type": "Effect", + "game_integration.mapping.event_type": "Event", + "game_integration.mapping.intensity": "Intensity", + "game_integration.mapping.priority": "Priority", + "game_integration.mapping.select_preset": "Load preset...", + "game_integration.mappings": "Mappings", + "game_integration.name": "Name:", + "game_integration.name.hint": "A descriptive name for this game integration", + "game_integration.no_config": "No configuration required for this adapter.", + "game_integration.preset.fps_combat": "FPS Combat", + "game_integration.preset.moba_health": "MOBA Health", + "game_integration.preset.select": "Load preset...", + "game_integration.section_title": "Game Integrations", + "game_integration.setup_instructions": "Setup Instructions", + "game_integration.setup_instructions.hint": "Follow these steps to configure your game to send data to this integration", + "game_integration.status": "Status", + "game_integration.status.active": "Active", + "game_integration.status.inactive": "Inactive", + "game_integration.test.button": "Test Connection", + "game_integration.test.error": "Connection error", + "game_integration.test.success": "Connection successful! Received events.", + "game_integration.test.timeout": "No events received within timeout period.", + "game_integration.test.waiting": "Waiting for events from game...", + "game_integration.updated": "Game integration updated", + "gradient.add": "Add Gradient", + "gradient.builtin": "Built-in", + "gradient.cloned": "Gradient cloned", + "gradient.confirm_delete": "Delete gradient \"{name}\"?", + "gradient.create_name": "New gradient name:", + "gradient.created": "Gradient created", + "gradient.deleted": "Gradient deleted", + "gradient.description": "Description:", + "gradient.description.hint": "Optional description for this gradient.", + "gradient.edit": "Edit Gradient", + "gradient.edit_name": "Rename gradient:", + "gradient.error.delete_failed": "Failed to delete gradient", + "gradient.error.min_stops": "At least 2 color stops are required", + "gradient.error.name_required": "Name is required", + "gradient.error.save_failed": "Failed to save gradient", + "gradient.group.title": "Gradients", + "gradient.name": "Name:", + "gradient.name.hint": "A descriptive name for this gradient.", + "gradient.stops_label": "stops", + "gradient.updated": "Gradient updated", + "graph.action.connect": "Connect", + "graph.action.disconnect": "Disconnect", + "graph.action.move": "Move node", + "graph.action.rewire": "Re-wire slot", + "graph.add_entity": "Add entity", + "graph.bulk_delete_confirm": "Delete {count} selected entities?", + "graph.choose_connection": "Choose connection", + "graph.color_picker": "Node color", + "graph.connection_failed": "Failed to update connection", + "graph.connection_removed": "Connection removed", + "graph.connection_updated": "Connection updated", + "graph.create_and_connect": "Create \u0026 connect…", + "graph.delete_with_dependents_confirm": "This entity is used by {count} other(s): {names}. Delete it and break those connections?", + "graph.disconnect": "Disconnect", + "graph.disconnect_failed": "Failed to disconnect", + "graph.duplicate": "Duplicate selection", + "graph.duplicate_done": "Duplicated {count} source(s)", + "graph.duplicate_done_warn": "Duplicated {count} source(s) — some references could not be remapped", + "graph.duplicate_failed": "Failed to duplicate selection", + "graph.duplicate_none": "Select one or more nodes to duplicate", + "graph.duplicate_none_eligible": "Nothing duplicable in the selection (only value \u0026 colour-strip sources)", + "graph.empty": "No entities yet", + "graph.empty.hint": "Create devices, sources, and targets to see them here.", + "graph.export": "Export graph (JSON)", + "graph.export_done": "Graph exported", + "graph.export_failed": "Failed to export graph", + "graph.filter": "Filter nodes", + "graph.filter_clear": "Clear filter", + "graph.filter_group.audio": "Audio", + "graph.filter_group.capture": "Capture", + "graph.filter_group.other": "Other", + "graph.filter_group.strip": "Color Strip", + "graph.filter_group.targets": "Targets", + "graph.filter_placeholder": "Filter: name, type:x, tag:x", + "graph.filter_running": "Running", + "graph.filter_stopped": "Stopped", + "graph.filter_types": "Types", + "graph.fit_all": "Fit all nodes", + "graph.fullscreen": "Toggle fullscreen", + "graph.help.add": "Add entity", + "graph.help.click": "Click", + "graph.help.click_desc": "Select node", + "graph.help.dblclick": "Double-click", + "graph.help.dblclick_desc": "Zoom to node", + "graph.help.delete": "Delete / Detach", + "graph.help.deselect": "Deselect", + "graph.help.drag_node": "Drag node", + "graph.help.drag_node_desc": "Reposition", + "graph.help.drag_port": "Drag port", + "graph.help.drag_port_desc": "Connect entities", + "graph.help.filter": "Filter", + "graph.help.fullscreen": "Fullscreen", + "graph.help.navigate": "Navigate nodes", + "graph.help.redo": "Redo", + "graph.help.right_click": "Right-click edge", + "graph.help.right_click_desc": "Detach connection", + "graph.help.search": "Search", + "graph.help.select_all": "Select all", + "graph.help.shift_click": "Shift+Click", + "graph.help.shift_click_desc": "Multi-select", + "graph.help.shift_drag": "Shift+Drag", + "graph.help.shift_drag_desc": "Rubber-band select", + "graph.help.shortcuts": "Shortcuts", + "graph.help.undo": "Undo", + "graph.help_title": "Keyboard Shortcuts", + "graph.issue.broken_ref": "Broken reference: {field}", + "graph.issue.cycle": "Part of a dependency cycle", + "graph.issues": "Issues", + "graph.issues_none": "No issues found", + "graph.legend": "Legend", + "graph.minimap": "Minimap", + "graph.no_compatible_connection": "No compatible connection between these entities", + "graph.nothing_to_redo": "Nothing to redo", + "graph.nothing_to_undo": "Nothing to undo", + "graph.redone": "Redone", + "graph.relayout": "Re-layout", + "graph.relayout_confirm": "Reset all manual node positions and re-layout the graph?", + "graph.replace_connection_confirm": "Replace the existing connection?", + "graph.rewire": "Re-wire…", + "graph.rewire_choose_source": "Choose a new source", + "graph.search": "Search nodes", + "graph.search_placeholder": "Search entities...", + "graph.title": "Graph", + "graph.tooltip.errors": "Errors", + "graph.tooltip.fps": "FPS", + "graph.tooltip.uptime": "Uptime", + "graph.undone": "Undone", + "graph.zoom_in": "Zoom in", + "graph.zoom_out": "Zoom out", + "ha_light.add": "Add HA Light Target", + "ha_light.button.turn_off": "Turn Off Lights", + "ha_light.color_source": "Color Source:", + "ha_light.color_source.color_vs": "Color value source", + "ha_light.color_source.css": "Color strip", + "ha_light.color_source.hint": "Pick a Color Strip Source (per-light LED ranges) or a Color Value Source (one colour broadcast to every light).", + "ha_light.color_tolerance": "Color Tolerance:", + "ha_light.color_tolerance.hint": "Skip sending color updates when the RGB delta is below this threshold. Reduces HA traffic for near-static scenes.", + "ha_light.connection_metric": "Home Assistant connection", + "ha_light.connection_tooltip": "HA Connection", + "ha_light.created": "HA light target created", + "ha_light.css_source": "Color Strip Source:", + "ha_light.description": "Description (optional):", + "ha_light.edit": "Edit HA Light Target", + "ha_light.error.color_source_required": "Color value source is required when broadcasting a single colour", + "ha_light.error.ha_source_required": "HA connection is required", + "ha_light.error.name_required": "Name is required", + "ha_light.ha_source": "HA Connection:", + "ha_light.lights.one": "{count} light", + "ha_light.lights.other": "{count} lights", + "ha_light.mapping.brightness": "Brightness Scale:", + "ha_light.mapping.entity_id": "Entity ID:", + "ha_light.mapping.led_end": "LED End (-1=last):", + "ha_light.mapping.led_start": "LED Start:", + "ha_light.mapping.search_entity": "Search light entities...", + "ha_light.mapping.select_entity": "Select a light entity...", + "ha_light.mapping.unassigned": "— No entity selected", + "ha_light.mappings": "Light Mappings:", + "ha_light.mappings.add": "Add Mapping", + "ha_light.mappings.color_vs_hint": "All listed lights will receive the same colour from the selected Color Value Source.", + "ha_light.mappings.hint": "Map LED ranges to HA light entities. Each mapping averages the LED segment to a single color.", + "ha_light.min_brightness_threshold": "Min Brightness Threshold:", + "ha_light.min_brightness_threshold.hint": "Effective output brightness below this value turns lights off completely (0 = disabled).", + "ha_light.name": "Name:", + "ha_light.name.placeholder": "Living Room Lights", + "ha_light.section.targets": "Light Targets", + "ha_light.section.title": "Home Assistant", + "ha_light.stop_action": "On Stop:", + "ha_light.stop_action.hint": "What to do with the mapped lights when this target stops streaming.", + "ha_light.stop_action.none": "None", + "ha_light.stop_action.none.desc": "Leave lights as-is", + "ha_light.stop_action.restore": "Restore", + "ha_light.stop_action.restore.desc": "Revert to state captured at start", + "ha_light.stop_action.turn_off": "Turn Off", + "ha_light.stop_action.turn_off.desc": "Switch all mapped lights off", + "ha_light.transition": "Transition:", + "ha_light.transition.hint": "Smooth fade duration between colors (HA transition parameter).", + "ha_light.turn_off.failed": "Failed to turn off lights", + "ha_light.turn_off.success": "Lights turned off", + "ha_light.update_rate": "Update Rate:", + "ha_light.update_rate.hint": "How often to send color updates to HA lights (0.5-5.0 Hz). Lower values are safer for HA performance.", + "ha_light.updated": "HA light target updated", + "ha_source.add": "Add Home Assistant Source", + "ha_source.connected": "Connected", + "ha_source.created": "Home Assistant source created", + "ha_source.delete.confirm": "Delete this Home Assistant connection?", + "ha_source.deleted": "Home Assistant source deleted", + "ha_source.description": "Description (optional):", + "ha_source.disconnected": "Disconnected", + "ha_source.edit": "Edit Home Assistant Source", + "ha_source.entity_filters": "Entity Filters (optional):", + "ha_source.entity_filters.hint": "Comma-separated glob patterns, e.g. sensor.*, binary_sensor.front_door. Leave empty for all.", + "ha_source.error.host_required": "Host is required", + "ha_source.error.load": "Failed to load Home Assistant source", + "ha_source.error.name_required": "Name is required", + "ha_source.error.token_required": "Access token is required", + "ha_source.group.title": "Home Assistant Sources", + "ha_source.host": "Host:", + "ha_source.host.hint": "Home Assistant host and port, e.g. 192.168.1.100:8123", + "ha_source.name": "Name:", + "ha_source.name.hint": "A descriptive name for this Home Assistant connection", + "ha_source.name.placeholder": "My Home Assistant", + "ha_source.test": "Test Connection", + "ha_source.test.failed": "Connection failed", + "ha_source.test.success": "Connected", + "ha_source.token": "Access Token:", + "ha_source.token.edit_hint": "Leave blank to keep the current token", + "ha_source.token.hint": "Long-Lived Access Token from HA (Profile \u003e Security \u003e Long-Lived Access Tokens)", + "ha_source.updated": "Home Assistant source updated", + "ha_source.use_ssl": "Use SSL (wss://)", + "ha_source.use_ssl.hint": "Enable for HTTPS / wss connections to Home Assistant", + "http_endpoint.add": "Add HTTP Endpoint", + "http_endpoint.auth.set": "Auth", + "http_endpoint.auth_token": "Auth Token (optional):", + "http_endpoint.auth_token.edit_hint": "Leave blank to keep the current token", + "http_endpoint.auth_token.hint": "Sent as \u0027Authorization: Bearer \u003ctoken\u003e\u0027. Add a custom Authorization header to override.", + "http_endpoint.auth_token.reveal": "Show / hide token", + "http_endpoint.created": "HTTP endpoint created", + "http_endpoint.delete.confirm": "Delete this HTTP endpoint? Value sources that reference it will need to be repointed.", + "http_endpoint.deleted": "HTTP endpoint deleted", + "http_endpoint.description": "Description (optional):", + "http_endpoint.edit": "Edit HTTP Endpoint", + "http_endpoint.error.load": "Failed to load HTTP endpoint", + "http_endpoint.error.name_required": "Name is required", + "http_endpoint.error.timeout_invalid": "Timeout must be a positive number", + "http_endpoint.error.url_required": "URL is required", + "http_endpoint.group.title": "HTTP Endpoints", + "http_endpoint.headers": "Custom Headers:", + "http_endpoint.headers.add": "Add header", + "http_endpoint.headers.count": "{n} headers", + "http_endpoint.headers.empty": "No custom headers — defaults will be sent.", + "http_endpoint.headers.hint": "Optional request headers (e.g. X-API-Key, Accept).", + "http_endpoint.headers.name_placeholder": "Header name", + "http_endpoint.headers.value_placeholder": "Header value", + "http_endpoint.method": "Method:", + "http_endpoint.method.get.desc": "Fetch the response body.", + "http_endpoint.method.head.desc": "Status code only — no body. Great for liveness probes.", + "http_endpoint.name": "Name:", + "http_endpoint.name.hint": "A descriptive name for this endpoint", + "http_endpoint.name.placeholder": "Plex now-playing", + "http_endpoint.section.headers": "Headers", + "http_endpoint.section.request": "Request", + "http_endpoint.test": "Test request", + "http_endpoint.test.body.json": "JSON body", + "http_endpoint.test.body.text": "Response body", + "http_endpoint.test.failed": "Failed", + "http_endpoint.test.pending": "Testing…", + "http_endpoint.test.success": "OK", + "http_endpoint.timeout": "Timeout (s):", + "http_endpoint.timeout.hint": "Maximum seconds to wait for a single request.", + "http_endpoint.updated": "HTTP endpoint updated", + "http_endpoint.url": "URL:", + "http_endpoint.url.hint": "Full http(s) URL to poll. Local addresses are allowed.", + "integrations.title": "Integrations", + "locale.change": "Change language", + "modal.discard_changes": "You have unsaved changes. Discard them?", + "mqtt_source.add": "Add MQTT Source", + "mqtt_source.base_topic": "Base Topic:", + "mqtt_source.base_topic.hint": "Prefix for status and state topics, e.g. ledgrab/status", + "mqtt_source.broker_host": "Broker Host:", + "mqtt_source.broker_host.hint": "MQTT broker hostname or IP address, e.g. 192.168.1.100", + "mqtt_source.broker_port": "Port:", + "mqtt_source.client_id": "Client ID:", + "mqtt_source.client_id.hint": "Unique MQTT client identifier. Change if running multiple instances.", + "mqtt_source.connected": "Connected", + "mqtt_source.created": "MQTT source created", + "mqtt_source.delete.confirm": "Delete this MQTT broker connection?", + "mqtt_source.deleted": "MQTT source deleted", + "mqtt_source.description": "Description (optional):", + "mqtt_source.disconnected": "Disconnected", + "mqtt_source.edit": "Edit MQTT Source", + "mqtt_source.error.host_required": "Broker host is required", + "mqtt_source.error.load": "Failed to load MQTT source", + "mqtt_source.error.name_required": "Name is required", + "mqtt_source.group.title": "MQTT Sources", + "mqtt_source.name": "Name:", + "mqtt_source.name.hint": "A descriptive name for this MQTT broker connection", + "mqtt_source.name.placeholder": "My MQTT Broker", + "mqtt_source.password": "Password (optional):", + "mqtt_source.password.edit_hint": "Leave blank to keep the current password", + "mqtt_source.test": "Test Connection", + "mqtt_source.test.failed": "Connection failed", + "mqtt_source.test.success": "Connected to broker", + "mqtt_source.updated": "MQTT source updated", + "mqtt_source.username": "Username (optional):", + "notifications.bulk.body": "{count} device events fired at once", + "notifications.bulk.title": "Multiple devices changed", + "notifications.device_discovered.body": "{device} appeared on your network", + "notifications.device_discovered.title": "New device found", + "notifications.device_lost.body": "{device} disappeared", + "notifications.device_lost.title": "Device lost", + "notifications.device_offline.body": "{device} stopped responding", + "notifications.device_offline.title": "Device offline", + "notifications.device_online.body": "{device} is back online", + "notifications.device_online.title": "Device online", + "notifications.test.body": "Notifications are wired up correctly.", + "notifications.test.title": "LedGrab test notification", + "notifications.unknown_device": "Unknown device", + "overlay.button.hide": "Hide overlay visualization", + "overlay.button.show": "Show overlay visualization", + "overlay.error.start": "Failed to start overlay", + "overlay.error.stop": "Failed to stop overlay", + "overlay.started": "Overlay visualization started", + "overlay.stopped": "Overlay visualization stopped", + "overlay.toggle": "Toggle screen overlay", + "pairing.cancel": "Cancel", + "pairing.close": "Close", + "pairing.failed": "Pairing failed: {detail}", + "pairing.failed_prefix": "Pairing failed:", + "pairing.instructions.default": "Press and hold the pairing button on your device, then click Start. Devices typically open a 30-second pairing window — be ready to act quickly.", + "pairing.not_ready": "Device didn\u0027t respond. Press the pairing button on your device, then try again.", + "pairing.pairing": "Pairing…", + "pairing.retry": "Try again", + "pairing.start": "Start pairing", + "pairing.success": "Paired successfully", + "pairing.title": "Pair Device", + "palette.search": "Search…", + "patch.checking": "CHECKING", + "patch.disconnected": "DISCONNECTED", + "patch.not_configured": "NOT CONFIGURED", + "patch.offline": "OFFLINE", + "patch.online": "ONLINE", + "patch.patched": "PATCHED", + "patch.paused": "PAUSED", + "patch.pipeline": "PIPELINE", + "patch.polling": "POLLING", + "patch.preset": "PRESET", + "patch.ready": "READY", + "patch.source": "SOURCE", + "patch.standby": "STANDBY", + "patch.streaming": "STREAMING", + "patch.strip": "STRIP", + "patch.template": "TEMPLATE", + "patch.ticking": "TICKING", + "patch.unreachable": "UNREACHABLE", + "patch.value": "VALUE", + "pattern.add": "Add Pattern Template", + "pattern.capture_bg": "Capture Background", + "pattern.created": "Pattern template created successfully", + "pattern.delete.confirm": "Are you sure you want to delete this pattern template?", + "pattern.delete.referenced": "Cannot delete: this template is referenced by a target", + "pattern.delete_selected": "Delete Selected", + "pattern.deleted": "Pattern template deleted successfully", + "pattern.description.hint": "Optional notes about where or how this pattern is used", + "pattern.description_label": "Description (optional):", + "pattern.description_placeholder": "Describe this pattern...", + "pattern.edit": "Edit Pattern Template", + "pattern.error.capture_bg_failed": "Failed to capture background", + "pattern.error.clone_failed": "Failed to clone pattern template", + "pattern.error.delete_failed": "Failed to delete pattern template", + "pattern.error.editor_open_failed": "Failed to open pattern template editor", + "pattern.error.required": "Please fill in all required fields", + "pattern.error.save_failed": "Failed to save pattern template", + "pattern.name": "Template Name:", + "pattern.name.hint": "A descriptive name for this rectangle layout", + "pattern.name.placeholder": "My Pattern Template", + "pattern.rect.add": "Add Rectangle", + "pattern.rect.empty": "No rectangles defined. Add at least one rectangle.", + "pattern.rect.height": "H", + "pattern.rect.name": "Name", + "pattern.rect.remove": "Remove", + "pattern.rect.width": "W", + "pattern.rect.x": "X", + "pattern.rect.y": "Y", + "pattern.rectangles": "Rectangles", + "pattern.rectangles.hint": "Fine-tune rectangle positions and sizes with exact coordinates (0.0 to 1.0)", + "pattern.source_for_bg": "Source for Background:", + "pattern.source_for_bg.none": "-- Select source --", + "pattern.updated": "Pattern template updated successfully", + "pattern.visual_editor": "Visual Editor", + "pattern.visual_editor.hint": "Click + buttons to add rectangles. Drag edges to resize, drag inside to move.", + "perf.all_online": "all online", + "perf.captures_count.one": "{count} capture", + "perf.captures_count.other": "{count} captures", + "perf.idle": "idle", + "perf.max_ms": "max {ms}ms", + "perf.no_captures": "no captures", + "perf.no_devices": "no devices", + "perf.offline": "offline", + "perf.offline_count": "{count} offline", + "perf.online": "online", + "perf.online_count": "{count} online", + "perf.ratio_of_requested": "{percent}% of requested · {captures}", + "perf.skipped_per_sec": "{rate} skipped/s", + "perf.targets_count.one": "{count} target", + "perf.targets_count.other": "{count} targets", + "perf.tip.ago": "−{seconds}s", + "perf.tip.now": "now", + "perf.total_bytes": "{bytes} total", + "perf.total_count": "{count} total", + "picture_source.type.video": "Video", + "picture_source.type.video.desc": "Stream frames from an uploaded video asset", + "picture_source.video.end_time": "End Time (s):", + "picture_source.video.loop": "Loop:", + "picture_source.video.resolution_limit": "Max Width (px):", + "picture_source.video.resolution_limit.hint": "Downscale video at decode time for performance", + "picture_source.video.speed": "Playback Speed:", + "picture_source.video.start_time": "Start Time (s):", + "playlists.action.start": "Start", + "playlists.action.stop": "Stop", + "playlists.add": "New Playlist", + "playlists.chip.loop": "Loop", + "playlists.chip.shuffle": "Shuffle", + "playlists.created": "Playlist created", + "playlists.delete_confirm": "Delete playlist \"{name}\"?", + "playlists.deleted": "Playlist deleted", + "playlists.description": "Description:", + "playlists.description.hint": "Optional description of what this playlist does", + "playlists.edit": "Edit Playlist", + "playlists.error.delete_failed": "Failed to delete playlist", + "playlists.error.name_required": "Name is required", + "playlists.error.no_presets": "Create a scene preset first", + "playlists.error.save_failed": "Failed to save playlist", + "playlists.error.start_failed": "Failed to start playlist", + "playlists.error.stop_failed": "Failed to stop playlist", + "playlists.item.duration": "Seconds", + "playlists.item.missing": "Missing", + "playlists.item.move_down": "Move down", + "playlists.item.move_up": "Move up", + "playlists.items.empty": "No scenes yet — add some below", + "playlists.items.search_placeholder": "Search scenes...", + "playlists.loop": "Loop:", + "playlists.loop.hint": "Restart from the first scene after the last one; off plays through once and stops", + "playlists.name": "Name:", + "playlists.name.placeholder": "My Playlist", + "playlists.scene_many": "scenes", + "playlists.scene_one": "scene", + "playlists.scenes": "Scenes:", + "playlists.scenes.add": "Add Scene", + "playlists.scenes.hint": "The scene presets this playlist cycles through, each held for its own duration", + "playlists.scenes_count": "scenes", + "playlists.section.playback": "Playback", + "playlists.shuffle": "Shuffle:", + "playlists.shuffle.hint": "Randomise the scene order at the start of every cycle", + "playlists.start": "Start playlist", + "playlists.started": "Playlist started", + "playlists.status.playing": "Playing", + "playlists.status.stopped": "Stopped", + "playlists.stop": "Stop playlist", + "playlists.stopped": "Playlist stopped", + "playlists.title": "Playlists", + "playlists.updated": "Playlist updated", + "postprocessing.add": "Add Filter Template", + "postprocessing.config.show": "Show settings", + "postprocessing.created": "Template created successfully", + "postprocessing.delete.confirm": "Are you sure you want to delete this filter template?", + "postprocessing.deleted": "Template deleted successfully", + "postprocessing.description": "Processing templates define image filters and color correction. Assign them to processed picture sources for consistent postprocessing across devices.", + "postprocessing.description_label": "Description (optional):", + "postprocessing.description_placeholder": "Describe this template...", + "postprocessing.edit": "Edit Filter Template", + "postprocessing.error.delete": "Failed to delete processing template", + "postprocessing.error.load": "Failed to load processing templates", + "postprocessing.error.required": "Please fill in all required fields", + "postprocessing.name": "Template Name:", + "postprocessing.name.placeholder": "My Filter Template", + "postprocessing.test.error.failed": "Processing template test failed", + "postprocessing.test.error.no_stream": "Please select a source", + "postprocessing.test.running": "Testing processing template...", + "postprocessing.test.source_stream": "Source:", + "postprocessing.test.title": "Test Filter Template", + "postprocessing.title": "Filter Templates", + "postprocessing.updated": "Template updated successfully", + "scene_preset.activated": "Preset activated", + "scene_preset.used_by": "Used by %d automation(s)", + "scenes.action.activate": "Activate", + "scenes.action.recapture": "Recapture", + "scenes.activate": "Activate scene", + "scenes.activated": "Scene activated", + "scenes.activated_partial": "Scene partially activated", + "scenes.add": "Capture Scene", + "scenes.capture": "Capture", + "scenes.captured": "Scene captured", + "scenes.cloned": "Scene cloned", + "scenes.delete": "Delete scene", + "scenes.delete_confirm": "Delete scene \"{name}\"?", + "scenes.deleted": "Scene deleted", + "scenes.description": "Description:", + "scenes.description.hint": "Optional description of what this scene does", + "scenes.edit": "Edit Scene", + "scenes.error.activate_failed": "Failed to activate scene", + "scenes.error.clone_failed": "Failed to clone scene", + "scenes.error.delete_failed": "Failed to delete scene", + "scenes.error.name_required": "Name is required", + "scenes.error.recapture_failed": "Failed to recapture scene", + "scenes.error.save_failed": "Failed to save scene", + "scenes.errors": "errors", + "scenes.name": "Name:", + "scenes.name.hint": "A descriptive name for this scene preset", + "scenes.name.placeholder": "My Scene", + "scenes.recapture": "Recapture current state", + "scenes.recapture_confirm": "Recapture current state into \"{name}\"?", + "scenes.recaptured": "Scene recaptured", + "scenes.status.preset": "Preset", + "scenes.targets": "Targets:", + "scenes.targets.add": "Add Target", + "scenes.targets.empty": "No targets selected", + "scenes.targets.hint": "Select which targets to include in this scene snapshot", + "scenes.targets.search_placeholder": "Search targets...", + "scenes.targets_count": "targets", + "scenes.title": "Scenes", + "scenes.updated": "Scene updated", + "search.action.activate": "Activate", + "search.action.disable": "Disable", + "search.action.enable": "Enable", + "search.action.start": "Start", + "search.action.stop": "Stop", + "search.footer": "↑↓ navigate · Enter select · Esc close", + "search.group.actions": "Actions", + "search.group.audio": "Audio Sources", + "search.group.automations": "Automations", + "search.group.capture_templates": "Capture Templates", + "search.group.cspt": "Strip Processing Templates", + "search.group.css": "Color Strip Sources", + "search.group.devices": "Devices", + "search.group.kc_targets": "Key Colors Targets", + "search.group.pattern_templates": "Pattern Templates", + "search.group.pp_templates": "Post-Processing Templates", + "search.group.scenes": "Scene Presets", + "search.group.streams": "Picture Streams", + "search.group.sync_clocks": "Sync Clocks", + "search.group.targets": "LED Targets", + "search.group.value": "Value Sources", + "search.loading": "Loading...", + "search.no_results": "No results found", + "search.open": "Search (Ctrl+K)", + "search.placeholder": "Search entities... (Ctrl+K)", + "section.collapse_all": "Collapse all sections", + "section.empty.assets": "No assets yet. Click + to upload one.", + "section.empty.audio_processing_templates": "No audio processing templates yet. Click + to create one.", + "section.empty.audio_sources": "No audio sources yet. Click + to add one.", + "section.empty.audio_templates": "No audio templates yet. Click + to add one.", + "section.empty.automations": "No automations yet. Click + to add one.", + "section.empty.capture_templates": "No capture templates yet. Click + to add one.", + "section.empty.color_strips": "No color strips yet. Click + to add one.", + "section.empty.cspt": "No CSS processing templates yet. Click + to add one.", + "section.empty.devices": "No devices yet. Click + to add one.", + "section.empty.game_integrations": "No game integrations yet. Click + to create one.", + "section.empty.gradients": "No gradients yet", + "section.empty.ha_light_targets": "No HA light targets yet. Click + to add one.", + "section.empty.ha_sources": "No Home Assistant sources yet. Click + to add one.", + "section.empty.http_endpoints": "No HTTP endpoints yet. Click + to add one.", + "section.empty.kc_targets": "No key color targets yet. Click + to add one.", + "section.empty.mqtt_sources": "No MQTT sources yet. Click + to add one.", + "section.empty.pattern_templates": "No pattern templates yet. Click + to add one.", + "section.empty.picture_sources": "No sources yet. Click + to add one.", + "section.empty.playlists": "No playlists yet. Click + to add one.", + "section.empty.pp_templates": "No post-processing templates yet. Click + to add one.", + "section.empty.scenes": "No scene presets yet. Click + to add one.", + "section.empty.sync_clocks": "No sync clocks yet. Click + to add one.", + "section.empty.targets": "No LED targets yet. Click + to add one.", + "section.empty.value_sources": "No value sources yet. Click + to add one.", + "section.empty.weather_sources": "No weather sources yet. Click + to add one.", + "section.empty.z2m_light_targets": "No Z2M light targets yet. Click + to add one.", + "section.expand_all": "Expand all sections", + "section.filter.placeholder": "Filter...", + "section.filter.reset": "Clear filter", + "section.hide": "Hide", + "section.show_hidden": "Show hidden cards", + "server.healthy": "Server online", + "server.offline": "Server offline", + "settings.activity_log.clear.auth_required": "Authentication required to clear the activity log", + "settings.activity_log.clear.button": "Clear Log", + "settings.activity_log.clear.confirm": "Clear all activity log entries? This cannot be undone.", + "settings.activity_log.clear.error": "Failed to clear activity log", + "settings.activity_log.clear.hint": "Permanently delete all activity log entries. This action is audited - a single system entry records who cleared the log and when.", + "settings.activity_log.clear.label": "Clear all entries", + "settings.activity_log.clear.success": "Activity log cleared", + "settings.activity_log.distinction_note": "This is the persistent audit log - structured records of every entity change, auth event, and system action. It is separate from the ephemeral debug Log Viewer (live server log tail, resets on disconnect).", + "settings.activity_log.enabled.hint": "When disabled, no new audit entries are recorded. Existing entries are preserved.", + "settings.activity_log.enabled.label": "Enable activity logging", + "settings.activity_log.error.max_days_range": "Max age must be between 0 and 3650 days", + "settings.activity_log.error.max_entries_range": "Max entries must be between 0 and 10,000,000", + "settings.activity_log.export.hint": "Download the full audit log as a file. Large logs may take a moment to prepare.", + "settings.activity_log.export.view_tab_hint": "To export with filters applied, use the", + "settings.activity_log.export_csv.button": "CSV", + "settings.activity_log.export_csv.label": "Export as CSV", + "settings.activity_log.export_json.button": "JSON", + "settings.activity_log.export_json.label": "Export as JSON", + "settings.activity_log.max_days.hint": "Entries older than this many days are pruned automatically. Set to 0 to disable age-based pruning (keep forever, up to the entry limit).", + "settings.activity_log.max_days.label": "Max age (days)", + "settings.activity_log.max_entries.hint": "Maximum number of entries to keep. When this limit is reached, the oldest entries are pruned. Set to 0 for no limit.", + "settings.activity_log.max_entries.label": "Max entries", + "settings.activity_log.open_activity_tab": "Activity tab", + "settings.activity_log.open_log_viewer": "debug Log Viewer", + "settings.activity_log.save": "Save Settings", + "settings.activity_log.save_error": "Failed to save settings", + "settings.activity_log.saved": "Activity log settings saved", + "settings.activity_log.section.clear": "Clear Log", + "settings.activity_log.section.export": "Export", + "settings.activity_log.section.retention": "Retention", + "settings.api_keys.empty": "No API keys configured", + "settings.api_keys.hint": "API keys are defined in the server config file (config.yaml). Edit the file and restart the server to apply changes.", + "settings.api_keys.label": "API Keys", + "settings.api_keys.load_error": "Failed to load API keys", + "settings.api_keys.meta.many": "keys", + "settings.api_keys.meta.one": "key", + "settings.api_keys.read_only": "Read-only", + "settings.auto_backup.backup_created": "Backup created", + "settings.auto_backup.backup_error": "Backup failed", + "settings.auto_backup.backup_now": "Backup Now", + "settings.auto_backup.enable": "Enable auto-backup", + "settings.auto_backup.hint": "Automatically create periodic backups of all configuration. Old backups are pruned when the maximum count is reached.", + "settings.auto_backup.interval_label": "Interval", + "settings.auto_backup.label": "Auto-Backup", + "settings.auto_backup.last_backup": "Last backup", + "settings.auto_backup.max_label": "Max backups", + "settings.auto_backup.never": "Never", + "settings.auto_backup.pill.running": "RUNNING", + "settings.auto_backup.save": "Save Settings", + "settings.auto_backup.save_error": "Failed to save auto-backup settings", + "settings.auto_backup.saved": "Auto-backup settings saved", + "settings.auto_shutdown": "Auto Restore:", + "settings.auto_shutdown.hint": "Restore device to idle state when targets stop or server shuts down", + "settings.backup.button": "Download", + "settings.backup.error": "Backup download failed", + "settings.backup.hint": "Download all configuration (devices, targets, streams, templates, automations) as a single JSON file.", + "settings.backup.label": "Backup Configuration", + "settings.backup.success": "Backup downloaded successfully", + "settings.brightness": "Brightness:", + "settings.brightness.hint": "Global brightness for this device (0-100%)", + "settings.button.cancel": "Cancel", + "settings.button.close": "Close", + "settings.button.save": "Save Changes", + "settings.capture.failed": "Failed to save capture settings", + "settings.capture.saved": "Capture settings updated", + "settings.capture.title": "Capture Settings", + "settings.capture_template": "Engine Template:", + "settings.capture_template.hint": "Screen capture engine and configuration for this device", + "settings.daylight_timezone.hint": "IANA timezone every \"real-time\" daylight cycle reads its wall clock in. Empty (default) uses the server\u0027s system timezone.", + "settings.daylight_timezone.label": "Daylight timezone", + "settings.daylight_timezone.save_error": "Failed to save daylight timezone", + "settings.daylight_timezone.saved": "Daylight timezone saved", + "settings.display_index": "Display:", + "settings.display_index.hint": "Which screen to capture for this device", + "settings.external_url.hint": "If set, this base URL is used in webhook URLs and other user-visible links instead of the auto-detected local IP. Example: https://myserver.example.com:8080", + "settings.external_url.label": "External URL", + "settings.external_url.placeholder": "https://myserver.example.com:8080", + "settings.external_url.save": "Save", + "settings.external_url.save_error": "Failed to save external URL", + "settings.external_url.saved": "External URL saved", + "settings.failed": "Failed to save settings", + "settings.fps": "Target FPS:", + "settings.fps.hint": "Target frames per second (10-90)", + "settings.general.title": "General Settings", + "settings.health_interval": "Health Check Interval (s):", + "settings.health_interval.hint": "How often to check the device status (5-600 seconds)", + "settings.log_level.desc.critical": "Fatal errors only", + "settings.log_level.desc.debug": "Verbose developer output", + "settings.log_level.desc.error": "Failures only", + "settings.log_level.desc.info": "Normal operation messages", + "settings.log_level.desc.warning": "Potential problems", + "settings.log_level.hint": "Change the server log verbosity at runtime. DEBUG shows the most detail; CRITICAL shows only fatal errors.", + "settings.log_level.label": "Log Level", + "settings.log_level.save": "Apply", + "settings.log_level.save_error": "Failed to change log level", + "settings.log_level.saved": "Log level changed", + "settings.logs.clear": "Clear", + "settings.logs.connect": "Connect", + "settings.logs.disconnect": "Disconnect", + "settings.logs.empty.sub": "Connect the WebSocket stream to begin tailing.", + "settings.logs.empty.title": "Awaiting log frames", + "settings.logs.error": "Log viewer connection failed", + "settings.logs.filter.all": "All levels", + "settings.logs.filter.all_desc": "Show all log messages", + "settings.logs.filter.error": "Error only", + "settings.logs.filter.error_desc": "Errors only", + "settings.logs.filter.info": "Info+", + "settings.logs.filter.info_desc": "Info, warning, and errors", + "settings.logs.filter.warning": "Warning+", + "settings.logs.filter.warning_desc": "Warnings and errors only", + "settings.logs.hint": "Stream live server log output. Use the filter to show only relevant log levels.", + "settings.logs.label": "Server Logs", + "settings.logs.open_viewer": "Open Log Viewer", + "settings.logs.patch.connecting": "CONNECTING", + "settings.logs.patch.error": "OFFLINE", + "settings.logs.patch.idle": "STANDBY", + "settings.logs.patch.live": "STREAMING", + "settings.logs.stat.err": "ERR", + "settings.logs.stat.lines": "LINES", + "settings.logs.stat.warn": "WARN", + "settings.logs.sub": "Live tail of server log output, filterable by level. Opens in a full-screen overlay.", + "settings.mqtt.base_topic_label": "Base Topic", + "settings.mqtt.client_id_label": "Client ID", + "settings.mqtt.enabled": "Enable MQTT", + "settings.mqtt.error_host_required": "Broker host is required", + "settings.mqtt.hint": "Configure MQTT broker connection for automation conditions and triggers.", + "settings.mqtt.host_label": "Broker Host", + "settings.mqtt.label": "MQTT", + "settings.mqtt.password_label": "Password", + "settings.mqtt.password_set_hint": "Password is set — leave blank to keep", + "settings.mqtt.port_label": "Port", + "settings.mqtt.save": "Save MQTT Settings", + "settings.mqtt.save_error": "Failed to save MQTT settings", + "settings.mqtt.saved": "MQTT settings saved", + "settings.mqtt.username_label": "Username", + "settings.notif_matrix.col.event": "Event", + "settings.notif_matrix.event_count": "4 EVENTS", + "settings.notifications.background.hint": "Continuously scan the LAN (mDNS) and serial bus for new LED devices. Disable to silence \"device discovered/lost\" events at the source. Restart the server to apply.", + "settings.notifications.background.label": "Background discovery", + "settings.notifications.background.toggle": "Enable background discovery", + "settings.notifications.channel.both.desc": "In-app toast and system notification", + "settings.notifications.channel.both.label": "Both", + "settings.notifications.channel.none.desc": "Silence this event", + "settings.notifications.channel.none.label": "Off", + "settings.notifications.channel.os.desc": "System notification (works while the browser is in the background)", + "settings.notifications.channel.os.label": "OS", + "settings.notifications.channel.snack.desc": "In-app toast at the bottom of the page", + "settings.notifications.channel.snack.label": "Snack", + "settings.notifications.intro_hint": "Pick how each device event reaches you. \"Snack\" shows an in-app toast, \"OS\" shows a system notification (browser must be granted permission), \"Both\" shows both, \"None\" silences the event entirely.", + "settings.notifications.intro_label": "Device events", + "settings.notifications.permission.denied": "OS notifications blocked — change in browser settings", + "settings.notifications.permission.grant": "Grant permission", + "settings.notifications.permission.granted": "OS notifications enabled", + "settings.notifications.permission.hint": "The browser controls OS notification permission per site. Once denied, LedGrab can no longer ask again — you need to clear it in the browser. Click the site icon (lock) in the address bar → Site settings → Notifications → Allow, then reload.", + "settings.notifications.permission.label": "OS notification permission", + "settings.notifications.permission.pill.denied": "BLOCKED", + "settings.notifications.permission.pill.granted": "GRANTED", + "settings.notifications.permission.state.default": "Not yet requested", + "settings.notifications.permission.state.denied": "Denied — change in browser settings", + "settings.notifications.permission.state.granted": "Granted — OS toasts will appear", + "settings.notifications.row.discovered": "New device found", + "settings.notifications.row.lost": "Discovered device lost", + "settings.notifications.row.offline": "Device went offline", + "settings.notifications.row.online": "Device came online", + "settings.notifications.save_error": "Failed to save notification preferences", + "settings.notifications.saved": "Notification preferences saved", + "settings.notifications.test_button": "Send a test notification", + "settings.partial.export_button": "Export", + "settings.partial.export_error": "Export failed", + "settings.partial.export_success": "Exported successfully", + "settings.partial.hint": "Export or import a single entity type. Import replaces or merges existing data and restarts the server.", + "settings.partial.import_button": "Import from File", + "settings.partial.import_confirm_merge": "This will MERGE into existing {store} data and restart the server. Continue?", + "settings.partial.import_confirm_replace": "This will REPLACE all {store} data and restart the server. Continue?", + "settings.partial.import_error": "Import failed", + "settings.partial.import_success": "Imported successfully", + "settings.partial.label": "Partial Export / Import", + "settings.partial.merge_label": "Merge (add/overwrite, keep existing)", + "settings.partial.store.audio_sources": "Audio Sources", + "settings.partial.store.audio_templates": "Audio Templates", + "settings.partial.store.automations": "Automations", + "settings.partial.store.capture_templates": "Capture Templates", + "settings.partial.store.color_strip_processing_templates": "CSS Processing Templates", + "settings.partial.store.color_strip_sources": "Color Strips", + "settings.partial.store.devices": "Devices", + "settings.partial.store.output_targets": "LED Targets", + "settings.partial.store.pattern_templates": "Pattern Templates", + "settings.partial.store.picture_sources": "Picture Sources", + "settings.partial.store.postprocessing_templates": "Post-processing Templates", + "settings.partial.store.scene_presets": "Scene Presets", + "settings.partial.store.sync_clocks": "Sync Clocks", + "settings.partial.store.value_sources": "Value Sources", + "settings.rail.group.system": "System", + "settings.rail.group.workspace": "Workspace", + "settings.restart.button": "Restart", + "settings.restart.sub": "Bounce the LedGrab process. Active capture and connected devices will pause for ~3 seconds.", + "settings.restart_confirm": "Restart the server? Active targets will be stopped.", + "settings.restart_server": "Restart Server", + "settings.restarting": "Restarting server...", + "settings.restore.button": "Restore", + "settings.restore.confirm": "This will replace ALL configuration and restart the server. Are you sure?", + "settings.restore.error": "Restore failed", + "settings.restore.hint": "Upload a previously downloaded backup file to replace all configuration. The server will restart automatically.", + "settings.restore.label": "Restore Configuration", + "settings.restore.restart_timeout": "Server did not respond. Please refresh the page manually.", + "settings.restore.restarting": "Server is restarting...", + "settings.restore.success": "Configuration restored", + "settings.save_bar.revert": "Revert", + "settings.save_bar.save": "Save", + "settings.save_bar.unsaved": "Unsaved changes in", + "settings.saved": "Settings saved successfully", + "settings.saved_backups.delete": "Delete", + "settings.saved_backups.delete_confirm": "Delete this backup file?", + "settings.saved_backups.delete_error": "Failed to delete backup", + "settings.saved_backups.download": "Download", + "settings.saved_backups.empty": "No saved backups", + "settings.saved_backups.hint": "Auto-backup files stored on the server. Download to save locally, or delete to free space.", + "settings.saved_backups.label": "Saved Backups", + "settings.saved_backups.restore": "Restore", + "settings.saved_backups.type.auto": "auto", + "settings.saved_backups.type.manual": "manual", + "settings.section.adapter": "Adapter", + "settings.section.api_keys": "Identity \u0026 API", + "settings.section.auth": "Auth", + "settings.section.behavior": "Behavior", + "settings.section.broker": "Broker", + "settings.section.configure": "Configure", + "settings.section.connection": "Connection", + "settings.section.controls": "Controls", + "settings.section.destructive": "DESTRUCTIVE", + "settings.section.diagnostics": "Diagnostics", + "settings.section.engine": "Engine", + "settings.section.file": "File", + "settings.section.filtering": "Filtering", + "settings.section.filters": "Filters", + "settings.section.gradient": "Gradient", + "settings.section.hardware": "Hardware", + "settings.section.history": "History", + "settings.section.identity": "Identity", + "settings.section.layout": "Layout", + "settings.section.lifecycle": "Lifecycle", + "settings.section.line_properties": "Line Properties", + "settings.section.loopback": "Loopback", + "settings.section.manual": "Manual", + "settings.section.mappings": "Mappings", + "settings.section.notes": "Notes", + "settings.section.notif_channels": "Channels", + "settings.section.notif_discovery": "Discovery", + "settings.section.notif_permission": "OS Permission", + "settings.section.offsets": "Offsets", + "settings.section.output": "Output", + "settings.section.preview": "Preview", + "settings.section.protocol": "Protocol", + "settings.section.provider": "Provider", + "settings.section.refresh": "Refresh", + "settings.section.restart": "Restart", + "settings.section.routing": "Routing", + "settings.section.server": "Server", + "settings.section.source": "Source", + "settings.section.strip": "Strip", + "settings.section.test": "Test", + "settings.section.test_setup": "Test Setup", + "settings.section.timing": "Timing", + "settings.section.type": "Type", + "settings.shutdown_action.hint": "What happens to LED targets when the server shuts down. \"Stop targets\" runs the normal stop sequence so devices with auto-restore restore their prior state. \"Nothing\" leaves the lights showing the last frame.", + "settings.shutdown_action.label": "Shutdown action", + "settings.shutdown_action.opt.nothing": "Nothing", + "settings.shutdown_action.opt.nothing_desc": "Leave lights showing the last frame", + "settings.shutdown_action.opt.stop": "Stop targets", + "settings.shutdown_action.opt.stop_desc": "Run the normal stop sequence (per-device auto-restore applies)", + "settings.shutdown_action.save_error": "Failed to save shutdown action", + "settings.shutdown_action.saved": "Shutdown action saved", + "settings.tab.about": "About", + "settings.tab.activity_log": "Activity Log", + "settings.tab.appearance": "Appearance", + "settings.tab.backup": "Backup", + "settings.tab.general": "General", + "settings.tab.mqtt": "MQTT", + "settings.tab.notifications": "Notifications", + "settings.tab.updates": "Updates", + "settings.title": "Settings", + "settings.url.hint": "IP address or hostname of the device", + "setup.copied": "Copied to clipboard", + "setup.copy": "Copy snippet", + "setup.description": "This LedGrab server has no API keys configured, so access from other devices on the network is disabled for security. Configure a key on the machine running the server to enable LAN access.", + "setup.hint_openssl": "Generate a strong key on Linux/macOS with \u003ccode\u003eopenssl rand -hex 32\u003c/code\u003e, or on Windows PowerShell with \u003ccode\u003e[guid]::NewGuid().ToString(\u0027N\u0027) + [guid]::NewGuid().ToString(\u0027N\u0027)\u003c/code\u003e.", + "setup.retry": "I\u0027ve configured a key — retry", + "setup.step1_label": "1. On the server machine, edit \u003ccode\u003econfig/default_config.yaml\u003c/code\u003e:", + "setup.step2_label": "2. Restart the server, then reload this page and log in with that key.", + "setup.step3_label": "Alternative: open LedGrab from the server machine itself (loopback), no key required:", + "setup.still_required": "Server still reports no API keys. Make sure you saved the config file and restarted the server.", + "setup.title": "Server setup required", + "sidebar.fps": "FPS", + "sidebar.load": "Load", + "sidebar.workspaces": "Workspaces", + "stream.error.clone_capture_failed": "Failed to clone capture template", + "stream.error.clone_picture_failed": "Failed to clone picture source", + "stream.error.clone_pp_failed": "Failed to clone postprocessing template", + "streams.add": "Add Source", + "streams.add.processed": "Add Processed Source", + "streams.add.raw": "Add Screen Capture", + "streams.add.static_image": "Add Static Image Source", + "streams.add.video": "Add Video Source", + "streams.capture_template": "Engine Template:", + "streams.capture_template.hint": "Engine template defining how the screen is captured", + "streams.created": "Source created successfully", + "streams.delete.confirm": "Are you sure you want to delete this source?", + "streams.deleted": "Source deleted successfully", + "streams.description": "Sources define the capture pipeline. A raw source captures from a display using a capture template. A processed source applies postprocessing to another source. Assign sources to devices.", + "streams.description_label": "Description (optional):", + "streams.description_placeholder": "Describe this source...", + "streams.display": "Display:", + "streams.display.hint": "Which screen to capture", + "streams.edit": "Edit Source", + "streams.edit.processed": "Edit Processed Source", + "streams.edit.raw": "Edit Screen Capture", + "streams.edit.static_image": "Edit Static Image Source", + "streams.edit.video": "Edit Video Source", + "streams.error.delete": "Failed to delete source", + "streams.error.load": "Failed to load sources", + "streams.error.required": "Please fill in all required fields", + "streams.group.assets": "Assets", + "streams.group.audio": "Audio", + "streams.group.audio_processing": "Audio Processing", + "streams.group.audio_templates": "Audio Templates", + "streams.group.color_strip": "Color Strips", + "streams.group.css_processing": "Processing Templates", + "streams.group.game": "Game Integration", + "streams.group.gradients": "Gradients", + "streams.group.home_assistant": "Home Assistant", + "streams.group.http": "HTTP", + "streams.group.mqtt": "MQTT", + "streams.group.proc_templates": "Filter Templates", + "streams.group.processed": "Sources", + "streams.group.raw": "Sources", + "streams.group.raw_templates": "Engine Templates", + "streams.group.static_image": "Static Image", + "streams.group.sync": "Sync Clocks", + "streams.group.value": "Value Sources", + "streams.group.video": "Video", + "streams.group.weather": "Weather", + "streams.image_asset": "Image Asset:", + "streams.image_asset.search": "Search image assets…", + "streams.image_asset.select": "Select image asset…", + "streams.modal.loading": "Loading...", + "streams.name": "Source Name:", + "streams.name.placeholder": "My Source", + "streams.pp_template": "Filter Template:", + "streams.pp_template.hint": "Filter template to apply to the source", + "streams.section.streams": "Sources", + "streams.source": "Source:", + "streams.source.hint": "The source to apply processing filters to", + "streams.target_fps": "Target FPS:", + "streams.target_fps.hint": "Target frames per second for capture (1-90)", + "streams.test.duration": "Capture Duration (s):", + "streams.test.error.failed": "Source test failed", + "streams.test.run": "Run", + "streams.test.running": "Testing source...", + "streams.test.title": "Test Source", + "streams.title": "Inputs", + "streams.type": "Type:", + "streams.type.processed": "Processed", + "streams.type.raw": "Screen Capture", + "streams.type.static_image": "Static Image", + "streams.updated": "Source updated successfully", + "streams.video_asset": "Video Asset:", + "streams.video_asset.search": "Search video assets…", + "streams.video_asset.select": "Select video asset…", + "sync_clock.action.pause": "Pause", + "sync_clock.action.reset": "Reset", + "sync_clock.action.resume": "Resume", + "sync_clock.add": "Add Sync Clock", + "sync_clock.created": "Sync clock created", + "sync_clock.delete.confirm": "Delete this sync clock? Linked sources will lose synchronization and run at default speed.", + "sync_clock.deleted": "Sync clock deleted", + "sync_clock.description": "Description (optional):", + "sync_clock.description.hint": "Optional notes about this clock\u0027s purpose", + "sync_clock.description.placeholder": "Optional description", + "sync_clock.edit": "Edit Sync Clock", + "sync_clock.elapsed": "Elapsed time", + "sync_clock.error.load": "Failed to load sync clock", + "sync_clock.error.name_required": "Clock name is required", + "sync_clock.group.title": "Sync Clocks", + "sync_clock.name": "Name:", + "sync_clock.name.hint": "A descriptive name for this synchronization clock", + "sync_clock.name.placeholder": "Main Animation Clock", + "sync_clock.paused": "Clock paused", + "sync_clock.reset_done": "Clock reset to zero", + "sync_clock.resumed": "Clock resumed", + "sync_clock.speed": "Speed:", + "sync_clock.speed.hint": "Animation speed multiplier for all linked sources. 1.0 = normal, 2.0 = double speed, 0.5 = half speed.", + "sync_clock.status.paused": "Paused", + "sync_clock.status.running": "Running", + "sync_clock.updated": "Sync clock updated", + "tags.hint": "Assign tags for grouping and filtering cards", + "tags.label": "Tags", + "tags.placeholder": "Add tag...", + "target.error.clone_failed": "Failed to clone target", + "target.error.delete_failed": "Failed to delete target", + "target.error.editor_open_failed": "Failed to open target editor", + "target.error.load_failed": "Failed to load target", + "target.error.start_failed": "Failed to start target", + "target.error.stop_failed": "Failed to stop target", + "targets.adaptive_fps": "Adaptive FPS:", + "targets.adaptive_fps.hint": "Automatically reduce send rate when the device becomes unresponsive, and gradually recover when it stabilizes. Recommended for WiFi devices with weak signal.", + "targets.add": "Add Target", + "targets.border_width": "Border Width (px):", + "targets.border_width.hint": "How many pixels from the screen edge to sample for LED colors (1-100)", + "targets.brightness": "Brightness:", + "targets.brightness.hint": "Output brightness multiplier (0–1). Can be bound to a value source for dynamic control.", + "targets.brightness_vs": "Brightness Source:", + "targets.brightness_vs.hint": "Optional value source that dynamically controls brightness each frame (overrides device brightness)", + "targets.brightness_vs.none": "None (device brightness)", + "targets.button.start": "Start", + "targets.button.stop": "Stop", + "targets.color_strip_source": "Color Strip Source:", + "targets.color_strip_source.hint": "Select the color strip source that provides LED colors for this target", + "targets.created": "Target created successfully", + "targets.delete.confirm": "Are you sure you want to delete this target?", + "targets.deleted": "Target deleted successfully", + "targets.description": "Targets bridge color strip sources to output devices. Each target references a device and a color strip source.", + "targets.device": "Device:", + "targets.device.hint": "Select the LED device to send data to", + "targets.device.none": "-- Select a device --", + "targets.edit": "Edit Target", + "targets.error.delete": "Failed to delete target", + "targets.error.load": "Failed to load targets", + "targets.error.name_required": "Please enter a target name", + "targets.error.required": "Please fill in all required fields", + "targets.failed": "Failed to load targets", + "targets.fps": "Target FPS:", + "targets.fps.hint": "Target frames per second for capture and LED updates (1-90)", + "targets.fps.rec": "Hardware max ≈ {fps} fps ({leds} LEDs)", + "targets.interpolation": "Interpolation Mode:", + "targets.interpolation.average": "Average", + "targets.interpolation.dominant": "Dominant", + "targets.interpolation.hint": "How to calculate LED color from sampled pixels", + "targets.interpolation.median": "Median", + "targets.keepalive_interval": "Keep Alive Interval:", + "targets.keepalive_interval.hint": "How often to resend the last frame when the source is static, keeping the device in live mode (0.5-5.0s)", + "targets.loading": "Loading targets...", + "targets.metrics.actual_fps": "Actual FPS", + "targets.metrics.errors": "Errors", + "targets.metrics.frames": "Frames", + "targets.metrics.target_fps": "Target FPS", + "targets.min_brightness_threshold": "Min Brightness Threshold:", + "targets.min_brightness_threshold.hint": "Effective output brightness (pixel brightness × device/source brightness) below this value turns LEDs off completely (0 = disabled)", + "targets.name": "Target Name:", + "targets.name.placeholder": "My Target", + "targets.no_css": "No source", + "targets.none": "No targets configured", + "targets.power_limit": "Max current (ABL):", + "targets.power_limit.hint": "Caps the strip\u0027s estimated current draw to your power-supply budget to prevent brownouts (voltage sag, color shift, flicker) on bright/white scenes. Set it to your PSU\u0027s rated current, leaving some headroom. 0 = unlimited.", + "targets.power_limit.ma_suffix": "mA (0 = unlimited)", + "targets.power_limit.per_led": "mA per LED (full white):", + "targets.protocol": "Protocol:", + "targets.protocol.ddp": "DDP (UDP)", + "targets.protocol.ddp.desc": "Fast raw UDP packets — recommended", + "targets.protocol.hint": "DDP sends pixels via fast UDP (recommended for most setups). HTTP uses the JSON API — slower but reliable, limited to ~500 LEDs.", + "targets.protocol.http": "HTTP", + "targets.protocol.http.desc": "JSON API — slower, ≤500 LEDs", + "targets.protocol.serial": "Serial", + "targets.protocol.udp": "WLED UDP (realtime)", + "targets.protocol.udp.desc": "WLED native realtime — RGBW whites + auto-revert if the stream drops", + "targets.section.advanced": "Advanced", + "targets.section.color_strips": "Color Strip Sources", + "targets.section.devices": "Devices", + "targets.section.pattern_templates": "Pattern Templates", + "targets.section.specific_settings": "Specific Settings", + "targets.section.targets": "Targets", + "targets.smoothing": "Smoothing:", + "targets.smoothing.hint": "Temporal blending between frames (0=none, 1=full). Reduces flicker.", + "targets.source": "Source:", + "targets.source.hint": "Which picture source to capture and process", + "targets.source.none": "-- No source assigned --", + "targets.status.error": "Error", + "targets.status.idle": "Idle", + "targets.status.processing": "Processing", + "targets.stop_all.button": "Stop All", + "targets.stop_all.error": "Failed to stop targets", + "targets.stop_all.none_running": "No targets are currently running", + "targets.stop_all.stopped": "Stopped {count} target(s)", + "targets.subtab.led": "LED", + "targets.subtab.wled": "LED", + "targets.title": "Channels", + "targets.updated": "Target updated successfully", + "templates.add": "Add Engine Template", + "templates.config": "Configuration", + "templates.config.camera_backend.auto": "Auto-detect best backend", + "templates.config.camera_backend.dshow": "Windows DirectShow", + "templates.config.camera_backend.msmf": "Windows Media Foundation", + "templates.config.camera_backend.v4l2": "Linux Video4Linux2", + "templates.config.default": "Default", + "templates.config.none": "No additional configuration", + "templates.config.resolution.1080p": "Full HD", + "templates.config.resolution.1440p": "QHD / 2K", + "templates.config.resolution.2160p": "4K UHD — highest CPU and bandwidth", + "templates.config.resolution.480p": "VGA — lowest CPU and bandwidth", + "templates.config.resolution.720p": "HD", + "templates.config.resolution.auto": "Open at the camera\u0027s max supported mode", + "templates.config.show": "Show configuration", + "templates.created": "Template created successfully", + "templates.delete.confirm": "Are you sure you want to delete this template?", + "templates.deleted": "Template deleted successfully", + "templates.description": "Capture templates define how the screen is captured. Each template uses a specific capture engine (MSS, DXcam, WGC) with custom settings. Assign templates to devices for optimal performance.", + "templates.description.label": "Description (optional):", + "templates.description.placeholder": "Describe this template...", + "templates.edit": "Edit Engine Template", + "templates.empty": "No capture templates configured", + "templates.engine": "Capture Engine:", + "templates.engine.android_camera.desc": "On-device camera capture (Camera2)", + "templates.engine.bettercam.desc": "DirectX, high performance", + "templates.engine.camera.desc": "USB/IP camera capture", + "templates.engine.demo.desc": "Animated test pattern (demo mode)", + "templates.engine.dxcam.desc": "DirectX, low latency", + "templates.engine.hint": "Select the screen capture technology to use", + "templates.engine.mediaprojection.desc": "Native Android screen capture", + "templates.engine.mss.desc": "Cross-platform, pure Python", + "templates.engine.scrcpy.desc": "Android screen mirror (adb screencap)", + "templates.engine.scrcpy_client.desc": "Android H.264 stream, high FPS", + "templates.engine.select": "Select an engine...", + "templates.engine.unavailable": "Unavailable", + "templates.engine.unavailable.hint": "This engine is not available on your system", + "templates.engine.wgc.desc": "Windows Graphics Capture", + "templates.error.delete": "Failed to delete template", + "templates.error.engines": "Failed to load engines", + "templates.error.load": "Failed to load templates", + "templates.error.load_failed": "Failed to load template", + "templates.error.required": "Please fill in all required fields", + "templates.error.save_failed": "Failed to save template", + "templates.loading": "Loading templates...", + "templates.name": "Template Name:", + "templates.name.placeholder": "My Custom Template", + "templates.test.border_width": "Border Width (px):", + "templates.test.description": "Test this template before saving to see a capture preview and performance metrics.", + "templates.test.device": "Device:", + "templates.test.device.select": "Select device...", + "templates.test.display": "Display:", + "templates.test.display.select": "Select display...", + "templates.test.duration": "Capture Duration (s):", + "templates.test.error.failed": "Test failed", + "templates.test.error.no_device": "Please select a device", + "templates.test.error.no_display": "Please select a display", + "templates.test.error.no_engine": "Please select a capture engine", + "templates.test.results.actual_fps": "Actual FPS", + "templates.test.results.avg_capture_time": "Avg Capture", + "templates.test.results.borders": "Border Extraction", + "templates.test.results.bottom": "Bottom", + "templates.test.results.capture_time": "Capture", + "templates.test.results.duration": "Duration", + "templates.test.results.extraction_time": "Extraction", + "templates.test.results.frame_count": "Frames", + "templates.test.results.left": "Left", + "templates.test.results.max_fps": "Max FPS", + "templates.test.results.performance": "Performance", + "templates.test.results.preview": "Full Capture Preview", + "templates.test.results.resolution": "Resolution:", + "templates.test.results.right": "Right", + "templates.test.results.top": "Top", + "templates.test.results.total_time": "Total", + "templates.test.run": "Run", + "templates.test.running": "Running test...", + "templates.test.title": "Test Capture", + "templates.title": "Engine Templates", + "templates.updated": "Template updated successfully", + "test.avg_capture": "Avg", + "test.fps": "FPS", + "test.frames": "Frames", + "theme.switched.dark": "Switched to dark theme", + "theme.switched.light": "Switched to light theme", + "theme.switched.system": "Switched to system theme", + "theme.toggle": "Toggle theme", + "time.days_ago": "{n}d ago", + "time.hours_ago": "{n}h ago", + "time.just_now": "just now", + "time.minutes_ago": "{n}m ago", + "time.seconds_ago": "{n}s ago", + "time.today": "Today", + "time.yesterday": "Yesterday", + "tour.accent": "Accent color — customize the UI accent color to your preference.", + "tour.activity_log": "The Activity tab is a persistent audit log of everything LedGrab has done — entity changes, auth events, device connections, and system actions. Filter, search, and export as CSV or JSON.", + "tour.api": "API Docs — interactive REST API documentation powered by Swagger.", + "tour.auto.add": "Click + to create a new automation with rules and a scene to activate.", + "tour.auto.card": "Each card shows automation status, rules, and quick controls to edit or toggle.", + "tour.auto.list": "Automations — automate scene activation based on time, audio, or value rules.", + "tour.auto.scenes_add": "Click + to capture the current system state as a new scene preset.", + "tour.auto.scenes_card": "Each scene card shows target/device counts. Click to edit, recapture, or activate.", + "tour.auto.scenes_list": "Scenes — saved system states that automations can activate or you can apply manually.", + "tour.automations": "Automations — automate scene switching with time, audio, or value rules.", + "tour.dash.automations": "Automations — active automation status and quick enable/disable toggle.", + "tour.dash.customize_btn": "Customize button — opens a side panel where you can reorder sections, toggle visibility, and tune the dashboard layout.", + "tour.dash.customize_global": "Global settings — content width, animation level, performance mode (system/app/both), and the sample window for charts.", + "tour.dash.customize_panel": "Dashboard Customize — your control center for layout. Changes apply live; close with Esc or the × button.", + "tour.dash.customize_perf_cells": "Performance cells — choose which metric tiles to show in the Performance section, configure their span, sample window, and Y-axis scale.", + "tour.dash.customize_presets": "Presets — apply curated layouts (compact, focused, full) in one click. Switching to a preset overrides any manual tweaks.", + "tour.dash.customize_sections": "Sections — drag to reorder; click the eye to hide; click the chevron to start collapsed by default. Each section can also be tagged dense/compact/comfortable.", + "tour.dash.integrations": "Integrations — connection status for Weather, Home Assistant, and MQTT sources.", + "tour.dash.perf": "Performance — real-time FPS charts, latency metrics, and poll interval control.", + "tour.dash.running": "Running targets — live streaming metrics and quick stop control.", + "tour.dash.scenes": "Scene Presets — saved system snapshots you can apply with a single click.", + "tour.dash.stopped": "Stopped targets — ready to start with one click.", + "tour.dash.sync_clocks": "Sync Clocks — shared timers that synchronize animations across sources.", + "tour.dash.targets": "Channels — all your targets in one place, grouped into running and stopped subsections.", + "tour.dashboard": "Dashboard — live overview of running targets, automations, and device health at a glance.", + "tour.graph": "Graph — visual overview of all entities and their connections. Drag ports to connect, right-click edges to disconnect.", + "tour.int.game": "Game — react to in-game events (Minecraft, Hammer of God, etc.) by triggering scenes and color effects from game state.", + "tour.int.home_assistant": "Home Assistant — connect to your HA instance. Lights, sensors, and entities become value sources and target endpoints.", + "tour.int.mqtt": "MQTT — subscribe to broker topics to feed external sensor data, automations, or remote triggers into LedGrab.", + "tour.int.nav": "Use this dropdown to switch between integration types: Weather, Home Assistant, MQTT, and Game integrations.", + "tour.int.weather": "Weather — pull live weather data (temperature, conditions, forecast) into value sources for environmental effects.", + "tour.integrations": "Integrations — connect external services: Weather, Home Assistant, MQTT, and Game integrations.", + "tour.language": "Language — choose your preferred interface language.", + "tour.restart": "Restart tutorial", + "tour.search": "Search — quickly find and navigate to any entity with Ctrl+K.", + "tour.settings": "Settings — backup and restore configuration, manage auto-backups.", + "tour.sources": "Sources — manage capture templates, picture sources, audio sources, and color strips.", + "tour.src.audio": "Audio — capture microphone/system audio and process it through templates for reactive effects.", + "tour.src.color_strip": "Color Strips — define how screen regions map to LED segments.", + "tour.src.nav": "Use this dropdown to switch between source types — pictures, color strips, audio, value, sync, and assets.", + "tour.src.processed": "Processed — apply post-processing effects like blur, brightness, or color correction.", + "tour.src.raw": "Raw — live screen capture sources from your displays.", + "tour.src.static": "Static Image — test your setup with image files instead of live capture.", + "tour.src.sync": "Sync Clocks — shared timers that synchronize animations across multiple sources.", + "tour.src.templates": "Capture Templates — reusable capture configurations (resolution, FPS, crop).", + "tour.src.value": "Value Sources — dynamic numbers or colors driven by time of day, audio, system metrics, Home Assistant entities, gradients, or schedules. Used to animate effects, modulate color strips, and trigger automations.", + "tour.targets": "Targets — add WLED devices, configure LED targets with capture settings and calibration.", + "tour.tgt.css": "Color Strips — define how screen regions map to LED segments.", + "tour.tgt.devices": "Devices — your LED controllers discovered on the network.", + "tour.tgt.ha_light_targets": "HA Light Targets — pick a Home Assistant light entity and a color/value source to drive it.", + "tour.tgt.nav": "Use this dropdown to switch between target types: LED devices, LED targets, and Home Assistant lights.", + "tour.tgt.targets": "LED Targets — combine a device, color strip, and capture source for streaming.", + "tour.theme": "Theme — switch between dark and light mode.", + "tour.welcome": "Welcome to LED Grab! This quick tour will show you around the interface. Use arrow keys or buttons to navigate.", + "transport.meta.cpu": "CPU", + "transport.meta.mem": "Mem", + "transport.meta.poll": "Poll", + "transport.meta.poll_hint": "Poll interval (click to cycle: 1s → 2s → 5s → 10s)", + "transport.meta.uptime": "Uptime", + "transport.status.armed": "Armed · {n} live", + "transport.status.ready": "Ready", + "tree.group.audio": "Audio", + "tree.group.audio_capture": "Capture", + "tree.group.audio_processed": "Processed", + "tree.group.capture": "Screen Capture", + "tree.group.game": "Game", + "tree.group.integrations": "Integrations", + "tree.group.picture": "Picture Source", + "tree.group.processing": "Processed", + "tree.group.static": "Static", + "tree.group.strip": "Color Strip", + "tree.group.utility": "Utility", + "tree.leaf.engine_templates": "Engine Templates", + "tree.leaf.filter_templates": "Filter Templates", + "tree.leaf.images": "Images", + "tree.leaf.processing_templates": "Processing Templates", + "tree.leaf.sources": "Sources", + "tree.leaf.templates": "Templates", + "tree.leaf.video": "Video", + "update.apply_confirm": "Download and install version {version}? The server will restart automatically.", + "update.apply_error": "Update failed", + "update.apply_now": "Update Now", + "update.applying": "Applying update…", + "update.assets.desc.android": "Android — sideload on Android 7.0+", + "update.assets.desc.android_bundle": "Android App Bundle (Play Store)", + "update.assets.desc.archive": "Compressed archive", + "update.assets.desc.ios": "iOS application", + "update.assets.desc.linux_appimage": "Linux portable — single executable", + "update.assets.desc.linux_deb": "Debian / Ubuntu package", + "update.assets.desc.linux_rpm": "Fedora / RHEL package", + "update.assets.desc.linux_sandbox": "Sandboxed Linux package", + "update.assets.desc.linux_tarball": "Linux archive — extract, run ./run.sh", + "update.assets.desc.macos_dmg": "macOS disk image — drag to Applications", + "update.assets.desc.macos_installer": "macOS installer package", + "update.assets.desc.tarball": "Tar archive", + "update.assets.desc.windows_exe": "Windows executable", + "update.assets.desc.windows_installer": "Windows installer — Start Menu shortcut, optional autostart, uninstaller", + "update.assets.desc.windows_msi": "Windows MSI installer", + "update.assets.desc.windows_portable": "Windows portable — unzip anywhere, run LedGrab.bat", + "update.assets.desc.zip_archive": "ZIP archive", + "update.assets.title": "Downloads", + "update.auto_check_hint": "Periodically check for new releases in the background.", + "update.auto_check_label": "Auto-Check Settings", + "update.available": "Version {version} is available", + "update.badge_tooltip": "New version available — click for details", + "update.channel.prerelease": "Pre-release", + "update.channel.prerelease_desc": "Include alpha, beta, and RC builds", + "update.channel.stable": "Stable", + "update.channel.stable_desc": "Stable releases only", + "update.channel_label": "Channel", + "update.check_error": "Update check failed", + "update.check_now": "Check for Updates", + "update.current_version": "Current version:", + "update.dismiss": "Dismiss", + "update.downloading": "Downloading…", + "update.enable": "Enable auto-check", + "update.install_type.dev": "Development", + "update.install_type.docker": "Docker", + "update.install_type.installer": "Windows installer", + "update.install_type.portable": "Portable", + "update.install_type_label": "Install type:", + "update.interval_label": "Check interval", + "update.last_check": "Last check", + "update.never": "never", + "update.pill.available": "UPDATE AVAILABLE", + "update.pill.error": "ERROR", + "update.pill.updated": "UP TO DATE", + "update.prerelease": "pre-release", + "update.release_notes": "Release Notes", + "update.release_notes_hint": "What\u0027s new in the available version — read the changelog before applying.", + "update.release_notes_open": "Open", + "update.save_settings": "Save Settings", + "update.settings_save_error": "Failed to save update settings", + "update.settings_saved": "Update settings saved", + "update.status_label": "Update Status", + "update.up_to_date": "You are running the latest version", + "update.view_release": "View Release", + "update.view_release_notes": "View Release Notes", + "validation.required": "This field is required", + "value_source.adaptive_max_value": "Max Value:", + "value_source.adaptive_max_value.hint": "Maximum output brightness", + "value_source.adaptive_min_value": "Min Value:", + "value_source.adaptive_min_value.hint": "Minimum output brightness", + "value_source.adaptive_time_color.schedule": "Color Schedule:", + "value_source.add": "Add Value Source", + "value_source.animated_color.clock": "Animation Clock:", + "value_source.animated_color.clock.hint": "Optional sync clock — when set, the clock controls timing (its speed multiplier scales the cpm above) and pause state. Leave empty to run on the value source\u0027s own speed.", + "value_source.animated_color.color_count": "colors", + "value_source.animated_color.colors": "Colors:", + "value_source.animated_color.easing": "Easing:", + "value_source.animated_color.easing.ease_in": "Ease In", + "value_source.animated_color.easing.ease_in.desc": "Slow start, accelerating into the next color", + "value_source.animated_color.easing.ease_in_out": "Ease In Out", + "value_source.animated_color.easing.ease_in_out.desc": "Smooth at both ends, faster in the middle", + "value_source.animated_color.easing.ease_out": "Ease Out", + "value_source.animated_color.easing.ease_out.desc": "Quick start, settling into the next color", + "value_source.animated_color.easing.linear": "Linear", + "value_source.animated_color.easing.linear.desc": "Constant rate blend between colors", + "value_source.animated_color.easing.sine": "Sine", + "value_source.animated_color.easing.sine.desc": "Sinusoidal curve — natural ambient cycling", + "value_source.animated_color.easing.step": "Step", + "value_source.animated_color.easing.step.desc": "Instant jump between colors", + "value_source.animated_color.speed": "Speed (cpm):", + "value_source.attribute": "Attribute (optional):", + "value_source.attribute.hint": "Read a specific attribute instead of the entity state", + "value_source.audio_max_value": "Max Value:", + "value_source.audio_max_value.hint": "Output at maximum audio level", + "value_source.audio_min_value": "Min Value:", + "value_source.audio_min_value.hint": "Output when audio is silent (e.g. 0.3 = 30% brightness floor)", + "value_source.audio_source": "Audio Source:", + "value_source.audio_source.hint": "Audio source to read audio levels from", + "value_source.auto_gain": "Auto Gain:", + "value_source.auto_gain.enable": "Enable auto-gain", + "value_source.auto_gain.hint": "Automatically normalize audio levels so output uses the full range, regardless of input volume", + "value_source.created": "Value source created", + "value_source.css_extract.source": "Color Strip Source", + "value_source.css_source": "Color Strip Source:", + "value_source.css_source.hint": "Color strip source to extract color from", + "value_source.daylight.enable_real_time": "Follow wall clock", + "value_source.daylight.latitude": "Latitude:", + "value_source.daylight.latitude.hint": "Your geographic latitude (-90 to 90). Steepens or flattens the sunrise/sunset edges of the cycle.", + "value_source.daylight.longitude": "Longitude:", + "value_source.daylight.longitude.hint": "Your geographic longitude (-180 to 180). Shifts solar noon for accurate sunrise/sunset alignment with your wall clock.", + "value_source.daylight.real_time": "Real-time", + "value_source.daylight.speed": "Speed:", + "value_source.daylight.speed.hint": "Cycle speed multiplier. 1.0 = full day/night cycle in ~4 minutes. Higher values cycle faster.", + "value_source.daylight.speed_label": "Speed", + "value_source.daylight.use_real_time": "Use Real Time:", + "value_source.daylight.use_real_time.hint": "When enabled, the value follows the actual time of day. Speed is ignored.", + "value_source.delete.confirm": "Are you sure you want to delete this value source?", + "value_source.deleted": "Value source deleted", + "value_source.description": "Description (optional):", + "value_source.description.hint": "Optional notes about this value source", + "value_source.description.placeholder": "Describe this value source...", + "value_source.disk_path": "Disk Path:", + "value_source.disk_path.hint": "Disk mount point or drive letter (e.g. / or C:\\)", + "value_source.easing": "Interpolation:", + "value_source.easing.hint": "How colors blend between stops", + "value_source.edit": "Edit Value Source", + "value_source.entity_id": "Entity:", + "value_source.entity_id.hint": "HA entity ID (e.g. sensor.temperature)", + "value_source.error.load": "Failed to load value source", + "value_source.error.name_required": "Please enter a name", + "value_source.error.schedule_min": "Schedule requires at least 2 time points", + "value_source.filter.all": "All", + "value_source.filter.color": "Color", + "value_source.filter.float": "Numeric", + "value_source.game_event.default_value": "Default Value:", + "value_source.game_event.default_value.hint": "Output value when no events received within timeout.", + "value_source.game_event.event_type": "Event Type:", + "value_source.game_event.event_type.hint": "The continuous game event to track (health, mana, ammo, etc.).", + "value_source.game_event.integration": "Game Integration:", + "value_source.game_event.integration.hint": "Select the game integration that provides events for this value source.", + "value_source.game_event.max_game_value": "Max Game Value:", + "value_source.game_event.max_game_value.hint": "Raw game value that maps to output 1.0.", + "value_source.game_event.min_game_value": "Min Game Value:", + "value_source.game_event.min_game_value.hint": "Raw game value that maps to output 0.0.", + "value_source.game_event.smoothing": "Smoothing:", + "value_source.game_event.smoothing.hint": "EMA smoothing factor. 0 = instant, higher = smoother transitions.", + "value_source.game_event.timeout": "Timeout (s):", + "value_source.game_event.timeout.hint": "Seconds of silence before reverting to the default value.", + "value_source.gradient_map.gradient": "Gradient", + "value_source.gradient_map.input": "Input Value Source", + "value_source.gradient_stops": "Gradient:", + "value_source.gradient_stops.hint": "Color stops for the gradient. Position 0 = input value 0, position 1 = input value 1", + "value_source.group.title": "Value Sources", + "value_source.ha_source": "HA Connection:", + "value_source.ha_source.hint": "Home Assistant connection to read entities from", + "value_source.http.endpoint": "HTTP Endpoint:", + "value_source.http.endpoint.hint": "Pick a saved endpoint from the HTTP integrations tab.", + "value_source.http.endpoint_required": "HTTP endpoint is required", + "value_source.http.interval": "Interval (s):", + "value_source.http.interval.hint": "Polling cadence in seconds. Multiple value sources can share one endpoint at different intervals.", + "value_source.http.interval_invalid": "Interval must be at least 1 second", + "value_source.http.json_path": "JSON Path:", + "value_source.http.json_path.hint": "Empty = use raw response body. Use dotted/indexed path, e.g. MediaContainer.Metadata[0].title", + "value_source.http.max_value": "Max Value:", + "value_source.http.max_value.hint": "Raw extracted value that maps to output 1.0 (for normalisation).", + "value_source.http.min_value": "Min Value:", + "value_source.http.min_value.hint": "Raw extracted value that maps to output 0.0 (for normalisation).", + "value_source.http.modulator.hint": "Only used when this source drives brightness or color. Automation rules read the raw extracted value and ignore these settings.", + "value_source.http.modulator.summary": "Modulator mapping (optional)", + "value_source.input_source": "Input Value Source:", + "value_source.input_source.hint": "Float value source (0-1) to map through the gradient", + "value_source.led_end": "LED End:", + "value_source.led_end.hint": "Last LED in the range (-1 = whole strip)", + "value_source.led_start": "LED Start:", + "value_source.led_start.hint": "First LED in the range (0-based)", + "value_source.max_ha_value": "Max HA Value:", + "value_source.max_ha_value.hint": "Raw HA value that maps to 100% output", + "value_source.max_rate": "Max Rate (bytes/sec):", + "value_source.max_rate.hint": "Maximum expected network rate in bytes/sec. 125000000 = 1 Gbps.", + "value_source.max_value": "Max Value:", + "value_source.max_value.hint": "Maximum output of the waveform cycle", + "value_source.metric": "Metric:", + "value_source.metric.battery_level": "Battery Level", + "value_source.metric.cpu_load": "CPU Load", + "value_source.metric.cpu_temp": "CPU Temperature", + "value_source.metric.disk_usage": "Disk Usage", + "value_source.metric.fan_speed": "Fan Speed", + "value_source.metric.gpu_load": "GPU Load", + "value_source.metric.gpu_temp": "GPU Temperature", + "value_source.metric.hint": "System metric to monitor. Some metrics may be unavailable depending on hardware.", + "value_source.metric.network_rx": "Network RX", + "value_source.metric.network_tx": "Network TX", + "value_source.metric.ram_usage": "RAM Usage", + "value_source.min_ha_value": "Min HA Value:", + "value_source.min_ha_value.hint": "Raw HA value that maps to 0% output", + "value_source.min_value": "Min Value:", + "value_source.min_value.hint": "Minimum output of the waveform cycle", + "value_source.mode": "Mode:", + "value_source.mode.beat": "Beat", + "value_source.mode.beat.desc": "Rhythm pulse detection", + "value_source.mode.hint": "RMS measures average volume. Peak tracks loudest moments. Beat triggers on rhythm.", + "value_source.mode.peak": "Peak", + "value_source.mode.peak.desc": "Loudest moment tracking", + "value_source.mode.rms": "RMS (Volume)", + "value_source.mode.rms.desc": "Average volume level", + "value_source.name": "Name:", + "value_source.name.hint": "A descriptive name for this value source", + "value_source.name.placeholder": "Brightness Pulse", + "value_source.normalize": "Normalize to 0–1:", + "value_source.normalize.hint": "On: rescale the raw value to 0–1 using Min/Max. Off: the value is clamped to 0–1 as-is (for sources that already report a 0–1 fraction). The raw value stays available to templates (raw[name]) and automations.", + "value_source.picture_source": "Picture Source:", + "value_source.picture_source.hint": "The picture source whose frames will be analyzed for average brightness.", + "value_source.poll_interval": "Poll Interval:", + "value_source.poll_interval.hint": "Seconds between metric reads", + "value_source.scene_behavior": "Behavior:", + "value_source.scene_behavior.complement": "Complement (dark → bright)", + "value_source.scene_behavior.hint": "Complement: dark scene = high brightness (ideal for ambient backlight). Match: bright scene = high brightness.", + "value_source.scene_behavior.match": "Match (bright → bright)", + "value_source.scene_sensitivity.hint": "Gain multiplier for the luminance signal (higher = more reactive to brightness changes)", + "value_source.schedule": "Schedule:", + "value_source.schedule.add": "+ Add Point", + "value_source.schedule.hint": "Define at least 2 time points. Brightness interpolates linearly between them, wrapping at midnight.", + "value_source.schedule.points": "points", + "value_source.select_type": "Select Value Source Type", + "value_source.sensitivity": "Sensitivity:", + "value_source.sensitivity.hint": "Gain multiplier for the audio signal (higher = more reactive)", + "value_source.sensor_label": "Sensor Label:", + "value_source.sensor_label.hint": "Optional sensor name to pick a specific sensor (empty = first available)", + "value_source.smoothing": "Smoothing:", + "value_source.smoothing.hint": "Temporal smoothing (0 = instant response, 1 = very smooth/slow)", + "value_source.speed": "Speed (cpm):", + "value_source.speed.hint": "Cycles per minute — how fast the waveform repeats (1 = very slow, 120 = very fast)", + "value_source.static_color.color": "Color:", + "value_source.sysmetric.max": "Max Value:", + "value_source.sysmetric.min": "Min Value:", + "value_source.template.add_input": "+ Add Input", + "value_source.template.default_value": "Default Value:", + "value_source.template.default_value.hint": "Output used when the expression cannot be evaluated (e.g. an input is missing).", + "value_source.template.error.cycle": "This expression would create a dependency cycle", + "value_source.template.error.duplicate_name": "Duplicate input name", + "value_source.template.error.invalid_expr": "Expression is invalid", + "value_source.template.error.invalid_name": "Invalid variable name", + "value_source.template.error.missing_input": "Every input needs a variable name", + "value_source.template.error.reserved_name": "Reserved name cannot be used as an input", + "value_source.template.error.unbound": "Expression references an unbound variable", + "value_source.template.eval_interval": "Eval Interval (s):", + "value_source.template.eval_interval.hint": "How often to re-evaluate the expression. 0 = every poll (re-evaluate as fast as the inputs update).", + "value_source.template.expression": "Expression:", + "value_source.template.expression.hint": "A sandboxed Jinja expression returning a number. Bound inputs are available by name; use raw[name] for the un-normalized value.", + "value_source.template.expression.placeholder": "clamp((temp - 18) / 10, 0, 1)", + "value_source.template.hints.examples_title": "Examples", + "value_source.template.hints.globals": "min(a, b), max(a, b), abs(x), round(x), clamp(x, lo, hi)", + "value_source.template.hints.globals_title": "Globals", + "value_source.template.hints.inputs_title": "Bound inputs", + "value_source.template.hints.no_inputs": "No inputs bound yet", + "value_source.template.hints.raw": "raw[name] gives the un-normalized value of an input that has one.", + "value_source.template.hints.raw_title": "Raw values", + "value_source.template.hints.time": "Tip: for time-of-day logic, bind an Adaptive (Time) or Daylight source as an input.", + "value_source.template.hints.title": "Expression help", + "value_source.template.input_count": "inputs", + "value_source.template.input_count_one": "input", + "value_source.template.input_name": "variable name", + "value_source.template.inputs": "Inputs:", + "value_source.template.inputs.empty": "No inputs yet. Click + to bind a value source.", + "value_source.template.inputs.hint": "Bind float value sources to variable names you reference in the expression.", + "value_source.template.valid": "Expression is valid", + "value_source.test": "Test", + "value_source.test.connecting": "Connecting...", + "value_source.test.current": "Current", + "value_source.test.error": "Failed to connect", + "value_source.test.max": "Max", + "value_source.test.min": "Min", + "value_source.test.title": "Test Value Source", + "value_source.type": "Type:", + "value_source.type.adaptive_scene": "Adaptive (Scene)", + "value_source.type.adaptive_scene.desc": "Adjusts by scene content", + "value_source.type.adaptive_time": "Adaptive (Time)", + "value_source.type.adaptive_time.desc": "Adjusts by time of day", + "value_source.type.adaptive_time_color": "Time Color", + "value_source.type.adaptive_time_color.desc": "24-hour color schedule", + "value_source.type.animated": "Animated", + "value_source.type.animated.desc": "Cycles through a waveform", + "value_source.type.animated_color": "Animated Color", + "value_source.type.animated_color.desc": "Cycles through colors", + "value_source.type.audio": "Audio", + "value_source.type.audio.desc": "Reacts to sound input", + "value_source.type.css_extract": "Strip Extract", + "value_source.type.css_extract.desc": "Extracts color from a color strip source", + "value_source.type.daylight": "Daylight Cycle", + "value_source.type.daylight.desc": "Value follows day/night cycle", + "value_source.type.game_event": "Game Event", + "value_source.type.game_event.desc": "Game metrics (health, ammo, mana) as 0-1 values", + "value_source.type.gradient_map": "Gradient Map", + "value_source.type.gradient_map.desc": "Maps numeric value through a color gradient", + "value_source.type.ha_entity": "HA Entity", + "value_source.type.ha_entity.desc": "Reads value from a Home Assistant sensor", + "value_source.type.hint": "Static outputs a constant value. Animated cycles through a waveform. Audio reacts to sound input. Adaptive types adjust brightness automatically based on time of day or scene content.", + "value_source.type.http": "HTTP Poll", + "value_source.type.http.desc": "Polls an HTTP endpoint at a fixed cadence and maps the extracted value to 0-1.", + "value_source.type.static": "Static", + "value_source.type.static.desc": "Constant output value", + "value_source.type.static_color": "Static Color", + "value_source.type.static_color.desc": "Fixed RGB color", + "value_source.type.system_metrics": "System Metrics", + "value_source.type.system_metrics.desc": "Monitor CPU, GPU, RAM, disk, network, battery, or fan speed", + "value_source.type.template": "Jinja Template", + "value_source.type.template.desc": "Combine bound inputs with a sandboxed Jinja expression to compute a 0-1 value.", + "value_source.updated": "Value source updated", + "value_source.value": "Value:", + "value_source.value.hint": "Constant output value (0.0 = off, 1.0 = full brightness)", + "value_source.waveform": "Waveform:", + "value_source.waveform.hint": "Shape of the brightness animation cycle", + "value_source.waveform.sawtooth": "Sawtooth", + "value_source.waveform.sine": "Sine", + "value_source.waveform.square": "Square", + "value_source.waveform.triangle": "Triangle", + "weather_source.add": "Add Weather Source", + "weather_source.created": "Weather source created", + "weather_source.delete.confirm": "Delete this weather source? Linked color strip sources will lose weather data.", + "weather_source.deleted": "Weather source deleted", + "weather_source.description": "Description (optional):", + "weather_source.description.placeholder": "Optional description", + "weather_source.edit": "Edit Weather Source", + "weather_source.error.load": "Failed to load weather source", + "weather_source.error.name_required": "Weather source name is required", + "weather_source.geo.error": "Geolocation failed", + "weather_source.geo.not_supported": "Geolocation is not supported by your browser", + "weather_source.geo.success": "Location detected", + "weather_source.group.title": "Weather Sources", + "weather_source.latitude": "Lat:", + "weather_source.location": "Location:", + "weather_source.location.hint": "Your geographic coordinates. Use the auto-detect button or enter manually.", + "weather_source.longitude": "Lon:", + "weather_source.name": "Name:", + "weather_source.name.hint": "A descriptive name for this weather data source", + "weather_source.name.placeholder": "My Weather", + "weather_source.provider": "Provider:", + "weather_source.provider.hint": "Weather data provider. Open-Meteo is free and requires no API key.", + "weather_source.provider.open_meteo.desc": "Free, no API key required", + "weather_source.test": "Test", + "weather_source.update_interval": "Update Interval:", + "weather_source.update_interval.hint": "How often to fetch weather data. Lower values give more responsive changes.", + "weather_source.updated": "Weather source updated", + "weather_source.use_my_location": "Use my location", + "weekday.short.0": "Mon", + "weekday.short.1": "Tue", + "weekday.short.2": "Wed", + "weekday.short.3": "Thu", + "weekday.short.4": "Fri", + "weekday.short.5": "Sat", + "weekday.short.6": "Sun", + "wizard.calibrate.desc": "Tell LedGrab where your LED strip starts and how it runs around the screen.", + "wizard.calibrate.skip": "Skip Calibration", + "wizard.calibrate.title": "Calibrate Strip Layout", + "wizard.device.desc": "Scan the network for compatible LED controllers, or add one manually.", + "wizard.device.discovered": "Discovered on network", + "wizard.device.existing": "Existing devices", + "wizard.device.manual.add": "Add Device", + "wizard.device.manual.led_count": "LED Count", + "wizard.device.manual.name": "Device Name", + "wizard.device.manual.name_placeholder": "My LED Strip", + "wizard.device.manual.title": "Add Manually", + "wizard.device.manual.url": "Device URL", + "wizard.device.none_found": "No devices found. Try adding one manually.", + "wizard.device.rescan": "Rescan", + "wizard.device.scanning": "Scanning network…", + "wizard.device.title": "Find Your Device", + "wizard.display.confirm": "Use This Screen", + "wizard.display.desc": "Select the monitor or display you want to capture for ambient lighting.", + "wizard.display.index_prefix": "Display", + "wizard.display.loading": "Loading displays…", + "wizard.display.manual_index": "Display Index", + "wizard.display.no_displays": "No displays detected. Enter the display index manually.", + "wizard.display.primary": "Primary", + "wizard.display.title": "Choose Your Screen", + "wizard.done.desc": "Your ambient LED setup is active. Enjoy the light!", + "wizard.done.device": "Device", + "wizard.done.display": "Screen", + "wizard.done.finish": "Finish", + "wizard.done.title": "All Done!", + "wizard.error.device_create_failed": "Failed to create device.", + "wizard.error.device_name_required": "Device name is required.", + "wizard.error.device_url_required": "Device URL is required.", + "wizard.error.no_device": "Please select or add a device first.", + "wizard.error.scaffold_failed": "Setup failed. Please try again.", + "wizard.error.start_failed": "Failed to start LED output.", + "wizard.modal.title": "Setup Wizard", + "wizard.rerun": "Rerun Setup Wizard", + "wizard.scaffold.building": "Creating entities…", + "wizard.scaffold.desc": "Creating the capture chain: screen source → color strip → LED output.", + "wizard.scaffold.done": "Setup complete! Ready to calibrate.", + "wizard.scaffold.title": "Building Setup", + "wizard.skip": "Skip", + "wizard.start": "Get Started", + "wizard.start.done": "LED output is running!", + "wizard.start.failed": "Failed to start output. You can start it manually from the Targets tab.", + "wizard.start.starting": "Starting LED output…", + "wizard.start.title": "Starting Output", + "wizard.step.calibrate": "Calibrate", + "wizard.step.device": "Device", + "wizard.step.display": "Screen", + "wizard.step.done": "Done", + "wizard.step.scaffold": "Setup", + "wizard.step.start": "Start", + "wizard.step.welcome": "Welcome", + "wizard.welcome.desc": "Let\u0027s get your LED strip up and running in just a few steps.", + "wizard.welcome.item1": "Connect your LED controller", + "wizard.welcome.item2": "Choose your screen to capture", + "wizard.welcome.item3": "Calibrate your strip layout", + "wizard.welcome.item4": "Start the ambient light output", + "wizard.welcome.title": "Welcome to LED Grab", + "z2m_light.add": "Add Z2M Light Target", + "z2m_light.base_topic": "Z2M Base Topic:", + "z2m_light.base_topic.hint": "Override only if your Zigbee2MQTT instance uses a non-default mqtt.base_topic.", + "z2m_light.brightness": "Brightness:", + "z2m_light.bulbs.one": "{count} bulb", + "z2m_light.bulbs.other": "{count} bulbs", + "z2m_light.button.turn_off": "Turn Off Bulbs", + "z2m_light.color_source": "Color Source:", + "z2m_light.color_source.color_vs": "Color value source", + "z2m_light.color_source.css": "Color strip", + "z2m_light.color_source.hint": "Pick a Color Strip Source (per-bulb LED ranges) or a Color Value Source (one colour broadcast to every bulb).", + "z2m_light.color_tolerance": "Color Tolerance:", + "z2m_light.color_tolerance.hint": "Skip publishes when the RGB delta is below this threshold. Reduces Zigbee mesh traffic for near-static scenes.", + "z2m_light.connection_metric": "Zigbee2MQTT broker connection", + "z2m_light.created": "Z2M light target created", + "z2m_light.description": "Description (optional):", + "z2m_light.edit": "Edit Z2M Light Target", + "z2m_light.error.color_source_required": "Color value source is required when broadcasting a single colour", + "z2m_light.error.mapping_required": "At least one bulb mapping is required", + "z2m_light.error.mqtt_source_required": "MQTT broker is required", + "z2m_light.error.name_required": "Name is required", + "z2m_light.mapping.brightness": "Brightness Scale:", + "z2m_light.mapping.friendly_name": "Friendly Name:", + "z2m_light.mapping.led_end": "LED End (-1=last):", + "z2m_light.mapping.led_start": "LED Start:", + "z2m_light.mapping.unassigned": "— No bulb selected", + "z2m_light.mappings": "Bulb Mappings:", + "z2m_light.mappings.add": "Add Mapping", + "z2m_light.mappings.color_vs_hint": "All listed bulbs will receive the same colour from the selected Color Value Source.", + "z2m_light.mappings.hint": "Map LED ranges to Z2M friendly names. Each mapping averages the LED segment to a single colour.", + "z2m_light.min_brightness_threshold": "Min Brightness Threshold:", + "z2m_light.min_brightness_threshold.hint": "Effective output brightness below this value turns bulbs off completely (0 = disabled).", + "z2m_light.mqtt_source": "MQTT Broker:", + "z2m_light.mqtt_source.hint": "Pick the MQTT source (broker) that Zigbee2MQTT is connected to. Manage brokers under MQTT Sources.", + "z2m_light.mqtt_source.none": "— Pick an MQTT broker", + "z2m_light.name": "Name:", + "z2m_light.name.placeholder": "Living Room Bulbs", + "z2m_light.section.targets": "Z2M Light Targets", + "z2m_light.section.title": "Zigbee2MQTT", + "z2m_light.stop_action": "On Stop:", + "z2m_light.stop_action.hint": "What to do with the mapped bulbs when this target stops streaming.", + "z2m_light.stop_action.none": "None", + "z2m_light.stop_action.none.desc": "Leave bulbs as-is", + "z2m_light.stop_action.turn_off": "Turn Off", + "z2m_light.stop_action.turn_off.desc": "Switch all mapped bulbs off", + "z2m_light.transition": "Transition:", + "z2m_light.transition.hint": "Smooth fade duration between colours, in seconds (Z2M transition parameter).", + "z2m_light.turn_off.failed": "Failed to turn off bulbs", + "z2m_light.turn_off.success": "Bulbs turned off", + "z2m_light.update_rate": "Update Rate:", + "z2m_light.update_rate.hint": "How often to publish colour updates to Z2M (0.5–10 Hz). Zigbee mesh tolerates ~5–10 Hz per bulb; higher values risk drops.", + "z2m_light.updated": "Z2M light target updated" } diff --git a/server/src/ledgrab/static/locales/ru.json b/server/src/ledgrab/static/locales/ru.json index 4f5047c..6b8223e 100644 --- a/server/src/ledgrab/static/locales/ru.json +++ b/server/src/ledgrab/static/locales/ru.json @@ -1,2990 +1,3146 @@ { - "app.title": "LED Grab", - "bindable.none": "Нет (статическое значение)", - "bindable.toggle": "Привязка к источнику значений", - "ha_light.color_tolerance": "Допуск цвета:", - "ha_light.color_tolerance.hint": "Пропускать обновление цвета, если разница RGB ниже этого порога. Снижает нагрузку на HA для статичных сцен.", - "ha_light.min_brightness_threshold": "Мин. порог яркости:", - "ha_light.min_brightness_threshold.hint": "Эффективная яркость ниже этого значения выключает свет полностью (0 = отключено).", - "ha_light.stop_action": "При остановке:", - "ha_light.stop_action.hint": "Что делать с привязанными лампами, когда цель прекращает стриминг.", - "ha_light.stop_action.none": "Ничего", - "ha_light.stop_action.none.desc": "Оставить лампы как есть", - "ha_light.stop_action.turn_off": "Выключить", - "ha_light.stop_action.turn_off.desc": "Выключить все привязанные лампы", - "ha_light.stop_action.restore": "Восстановить", - "ha_light.stop_action.restore.desc": "Вернуть состояние на момент запуска", - "ha_light.button.turn_off": "Выключить лампы", - "ha_light.turn_off.success": "Лампы выключены", - "ha_light.turn_off.failed": "Не удалось выключить лампы", - "confirm.turn_off_ha_light": "Выключить все привязанные лампы?", - "ha_light.connection_tooltip": "Подключение HA", - "ha_light.connection_metric": "Подключение Home Assistant", - "ha_light.lights.one": "{count} лампа", - "ha_light.lights.few": "{count} лампы", - "ha_light.lights.many": "{count} ламп", - "ha_light.color_source": "Источник цвета:", - "ha_light.color_source.hint": "Выберите Источник полосы цвета (диапазоны LED для каждой лампы) или Источник значения цвета (один цвет на все лампы).", - "ha_light.color_source.css": "Полоса цвета", - "ha_light.color_source.color_vs": "Источник значения цвета", - "ha_light.mappings.color_vs_hint": "Все указанные лампы получат один и тот же цвет от выбранного Источника значения цвета.", - "ha_light.mapping.unassigned": "— Сущность не выбрана", - "ha_light.error.color_source_required": "Источник значения цвета обязателен в режиме одного цвета", - "ha_light.section.title": "Home Assistant", - "ha_light.section.targets": "Цели освещения", - "ha_light.add": "Добавить HA-цель освещения", - "ha_light.edit": "Редактировать HA-цель освещения", - "ha_light.created": "HA-цель освещения создана", - "ha_light.updated": "HA-цель освещения обновлена", - "ha_light.name": "Имя:", - "ha_light.name.placeholder": "Лампы гостиной", - "ha_light.description": "Описание (опционально):", - "ha_light.ha_source": "HA-подключение:", - "ha_light.css_source": "Источник полосы цвета:", - "ha_light.update_rate": "Частота обновления:", - "ha_light.update_rate.hint": "Как часто отправлять обновления цвета лампам HA (0.5-5.0 Гц). Меньшие значения безопаснее для производительности HA.", - "ha_light.transition": "Переход:", - "ha_light.transition.hint": "Длительность плавного перехода между цветами (параметр HA transition).", - "ha_light.mappings": "Привязки ламп:", - "ha_light.mappings.add": "Добавить привязку", - "ha_light.mappings.hint": "Сопоставьте диапазоны LED с сущностями ламп HA. Каждая привязка усредняет LED-сегмент до одного цвета.", - "ha_light.mapping.entity_id": "ID сущности:", - "ha_light.mapping.led_start": "Начало LED:", - "ha_light.mapping.led_end": "Конец LED (-1=последний):", - "ha_light.mapping.brightness": "Множитель яркости:", - "ha_light.mapping.search_entity": "Поиск сущностей-ламп...", - "ha_light.mapping.select_entity": "Выберите сущность-лампу...", - "ha_light.error.name_required": "Требуется имя", - "ha_light.error.ha_source_required": "Требуется HA-подключение", - "ha_light.lights.other": "{count} ламп", - "app.version": "Версия:", - "app.api_docs": "Документация API", - "app.connection_lost": "Сервер недоступен", - "app.connection_retrying": "Попытка переподключения…", - "app.server_restarting": "Сервер перезапускается…", - "app.server_restarting_sub": "Пожалуйста, подождите, сервер скоро вернётся.", - "demo.badge": "ДЕМО", - "demo.banner": "Вы в демо-режиме — все устройства и данные виртуальные. Реальное оборудование не используется.", - "theme.toggle": "Переключить тему", - "bg.anim.toggle": "Анимированный фон", - "accent.title": "Цвет акцента", - "accent.custom": "Свой", - "accent.reset": "Сброс", - "locale.change": "Изменить язык", - "auth.login": "Войти", - "auth.logout": "Выйти", - "auth.authenticated": "● Авторизован", - "auth.title": "Вход в LED Grab", - "auth.message": "Пожалуйста, введите ваш API ключ для аутентификации и доступа к LED Grab.", - "auth.label": "API Ключ:", - "auth.placeholder": "Введите ваш API ключ...", - "auth.hint": "Ваш API ключ будет безопасно сохранен в локальном хранилище браузера.", - "auth.button.cancel": "Отмена", - "auth.button.login": "Войти", - "auth.error.required": "Пожалуйста, введите API ключ", - "auth.error.invalid": "Неверный API ключ. Попробуйте ещё раз.", - "auth.success": "Вход выполнен успешно!", - "auth.logout.confirm": "Вы уверены, что хотите выйти?", - "auth.logout.success": "Выход выполнен успешно", - "auth.please_login": "Пожалуйста, войдите для просмотра", - "auth.session_expired": "Ваша сессия истекла или API ключ недействителен. Пожалуйста, войдите снова.", - "auth.toggle_password": "Показать/скрыть пароль", - "auth.prompt_enter": "Enter your API key:", - "auth.prompt_update": "Current API key is set. Enter new key to update or leave blank to remove:", - "api_key.login": "Войти", - "setup.title": "Требуется настройка сервера", - "setup.description": "На этом сервере LedGrab не настроены API-ключи, поэтому доступ с других устройств в сети отключён из соображений безопасности. Чтобы разрешить доступ по локальной сети, настройте ключ на машине, где работает сервер.", - "setup.step1_label": "1. На машине с сервером откройте config/default_config.yaml:", - "setup.step2_label": "2. Перезапустите сервер, затем перезагрузите эту страницу и войдите с этим ключом.", - "setup.step3_label": "Либо откройте LedGrab прямо на машине с сервером (через loopback), ключ не нужен:", - "setup.hint_openssl": "Сгенерировать надёжный ключ: Linux/macOS — openssl rand -hex 32; Windows PowerShell — [guid]::NewGuid().ToString('N') + [guid]::NewGuid().ToString('N').", - "setup.copy": "Скопировать фрагмент", - "setup.copied": "Скопировано в буфер обмена", - "setup.retry": "Я настроил ключ — проверить снова", - "setup.still_required": "Сервер всё ещё сообщает об отсутствии API-ключей. Проверьте, что файл сохранён и сервер перезапущен.", - "displays.title": "Доступные Дисплеи", - "displays.layout": "Дисплеи", - "displays.information": "Информация о Дисплеях", - "displays.legend.primary": "Основной Дисплей", - "displays.legend.secondary": "Вторичный Дисплей", - "displays.badge.primary": "Основной", - "displays.badge.secondary": "Вторичный", - "displays.resolution": "Разрешение:", - "displays.refresh_rate": "Частота Обновления:", - "displays.position": "Позиция:", - "displays.index": "Индекс Дисплея:", - "displays.loading": "Загрузка дисплеев...", - "displays.none": "Нет доступных дисплеев", - "displays.failed": "Не удалось загрузить дисплеи", - "displays.picker.title": "Выберите Дисплей", - "displays.picker.title.lead": "Выбрать", - "displays.picker.title.accent": "Дисплей", - "displays.picker.eyebrow.label": "Источник", - "displays.picker.eyebrow.channel": "Дисплей · Карта", - "displays.picker.eyebrow.channel.device": "Устройство · Список", - "displays.picker.foot.dismiss": "закрыть", - "displays.picker.foot.select": "выбрать монитор", - "displays.picker.foot.select.device": "выбрать устройство", - "displays.picker.select": "Выберите дисплей...", - "displays.picker.click_to_select": "Нажмите, чтобы выбрать этот дисплей", - "displays.picker.adb_connect": "Подключить ADB устройство", - "displays.picker.adb_connect.placeholder": "IP адрес (напр. 192.168.2.201)", - "displays.picker.adb_connect.button": "Подключить", - "displays.picker.adb_connect.success": "Устройство подключено", - "displays.picker.adb_connect.error": "Не удалось подключить устройство", - "displays.picker.adb_disconnect": "Отключить", - "displays.picker.no_android": "Android устройства не найдены. Подключите по USB или введите IP выше.", - "templates.title": "Шаблоны Движков", - "templates.description": "Шаблоны захвата определяют, как захватывается экран. Каждый шаблон использует определённый движок захвата (MSS, DXcam, WGC) с настраиваемыми параметрами. Назначайте шаблоны устройствам для оптимальной производительности.", - "templates.loading": "Загрузка шаблонов...", - "templates.empty": "Шаблоны захвата не настроены", - "templates.add": "Добавить Шаблон Движка", - "templates.edit": "Редактировать Шаблон Движка", - "templates.name": "Имя Шаблона:", - "templates.name.placeholder": "Мой Пользовательский Шаблон", - "templates.description.label": "Описание (необязательно):", - "templates.description.placeholder": "Опишите этот шаблон...", - "templates.engine": "Движок Захвата:", - "templates.engine.hint": "Выберите технологию захвата экрана", - "templates.engine.select": "Выберите движок...", - "templates.engine.unavailable": "Недоступен", - "templates.engine.unavailable.hint": "Этот движок недоступен в вашей системе", - "templates.engine.mss.desc": "Кроссплатформенный, чистый Python", - "templates.engine.dxcam.desc": "DirectX, низкая задержка", - "templates.engine.bettercam.desc": "DirectX, высокая производительность", - "templates.engine.camera.desc": "Захват USB/IP камеры", - "templates.engine.scrcpy.desc": "Зеркалирование экрана Android (adb screencap)", - "templates.engine.scrcpy_client.desc": "Android H.264 поток, высокая частота", - "templates.engine.wgc.desc": "Windows Graphics Capture", - "templates.engine.demo.desc": "Тестовый анимированный шаблон (демо)", - "templates.engine.mediaprojection.desc": "Нативный захват экрана Android", - "templates.engine.android_camera.desc": "Захват камеры устройства (Camera2)", - "templates.config": "Конфигурация", - "templates.config.show": "Показать конфигурацию", - "templates.config.none": "Нет дополнительных настроек", - "templates.config.default": "По умолчанию", - "templates.config.camera_backend.auto": "Автовыбор лучшего бэкенда", - "templates.config.camera_backend.dshow": "Windows DirectShow", - "templates.config.camera_backend.msmf": "Windows Media Foundation", - "templates.config.camera_backend.v4l2": "Linux Video4Linux2", - "templates.config.resolution.auto": "Открыть в максимальном поддерживаемом разрешении камеры", - "templates.config.resolution.480p": "VGA — минимум CPU и трафика", - "templates.config.resolution.720p": "HD", - "templates.config.resolution.1080p": "Full HD", - "templates.config.resolution.1440p": "QHD / 2K", - "templates.config.resolution.2160p": "4K UHD — максимум CPU и трафика", - "templates.created": "Шаблон успешно создан", - "templates.updated": "Шаблон успешно обновлён", - "templates.deleted": "Шаблон успешно удалён", - "templates.delete.confirm": "Вы уверены, что хотите удалить этот шаблон?", - "templates.error.load": "Не удалось загрузить шаблоны", - "templates.error.engines": "Не удалось загрузить движки", - "templates.error.required": "Пожалуйста, заполните все обязательные поля", - "templates.error.delete": "Не удалось удалить шаблон", - "templates.error.save_failed": "Не удалось сохранить шаблон", - "templates.error.load_failed": "Не удалось загрузить шаблон", - "templates.test.title": "Тест Захвата", - "templates.test.description": "Протестируйте этот шаблон перед сохранением, чтобы увидеть предпросмотр захвата и метрики производительности.", - "templates.test.display": "Дисплей:", - "templates.test.display.select": "Выберите дисплей...", - "templates.test.device": "Устройство:", - "templates.test.device.select": "Выберите устройство...", - "templates.test.duration": "Длительность Захвата (с):", - "templates.test.border_width": "Ширина Границы (px):", - "templates.test.run": "Запустить", - "templates.test.running": "Выполняется тест...", - "templates.test.results.preview": "Полный Предпросмотр Захвата", - "templates.test.results.borders": "Извлечение Границ", - "templates.test.results.top": "Сверху", - "templates.test.results.right": "Справа", - "templates.test.results.bottom": "Снизу", - "templates.test.results.left": "Слева", - "templates.test.results.performance": "Производительность", - "templates.test.results.capture_time": "Захват", - "templates.test.results.extraction_time": "Извлечение", - "templates.test.results.total_time": "Всего", - "templates.test.results.max_fps": "Макс. FPS", - "templates.test.results.duration": "Длительность", - "templates.test.results.frame_count": "Кадры", - "templates.test.results.actual_fps": "Факт. FPS", - "templates.test.results.avg_capture_time": "Средн. Захват", - "templates.test.results.resolution": "Разрешение:", - "templates.test.error.no_engine": "Пожалуйста, выберите движок захвата", - "templates.test.error.no_display": "Пожалуйста, выберите дисплей", - "templates.test.error.no_device": "Пожалуйста, выберите устройство", - "templates.test.error.failed": "Тест не удался", - "devices.title": "Устройства", - "device.select_type": "Выберите тип устройства", - "devices.add": "Добавить Новое Устройство", - "devices.loading": "Загрузка устройств...", - "devices.none": "Устройства не настроены", - "devices.failed": "Не удалось загрузить устройства", - "devices.wled_config": "Конфигурация WLED:", - "devices.wled_note": "Настройте ваше WLED устройство (эффекты, сегменты, порядок цветов, ограничения питания и т.д.) используя", - "devices.wled_link": "официальное приложение WLED", - "devices.wled_note_or": "или встроенный", - "devices.wled_webui_link": "веб-интерфейс WLED", - "devices.wled_note_webui": "(откройте IP устройства в браузере).", - "devices.wled_note2": "Этот контроллер отправляет данные о цвете пикселей и управляет яркостью для каждого устройства.", - "device.scan": "Автопоиск", - "device.scan.empty": "Устройства не найдены", - "device.scan.error": "Ошибка сканирования сети", - "device.scan.already_added": "Уже добавлено", - "device.scan.selected": "Устройство выбрано", - "device.type": "Тип устройства:", - "device.type.hint": "Выберите тип LED контроллера", - "device.type.wled": "WLED", - "device.type.wled.desc": "WiFi LED контроллер по HTTP/UDP", - "device.type.adalight": "Adalight", - "device.type.adalight.desc": "Серийный протокол для Arduino", - "device.type.ambiled": "AmbiLED", - "device.type.ambiled.desc": "Серийный протокол AmbiLED", - "device.type.mqtt": "MQTT", - "device.type.mqtt.desc": "Отправка LED данных через MQTT брокер", - "device.type.ws": "WebSocket", - "device.type.ws.desc": "Стриминг LED данных через WebSocket", - "device.type.openrgb": "OpenRGB", - "device.type.openrgb.desc": "Управление RGB через OpenRGB", - "device.type.dmx": "DMX", - "device.type.dmx.desc": "Art-Net / sACN (E1.31) сценическое освещение", - "device.type.ddp": "DDP", - "device.type.ddp.desc": "Прямая отправка пикселей по UDP (Pixelblaze, ESPixelStick, Falcon)", - "device.type.opc": "OPC", - "device.type.opc.desc": "Open Pixel Control (Fadecandy, xLights, любительские драйверы)", - "device.type.mock": "Mock", - "device.type.mock.desc": "Виртуальное устройство для тестов", - "device.type.espnow": "ESP-NOW", - "device.type.espnow.desc": "Ultra-low-latency via ESP32 gateway", - "device.type.hue": "Philips Hue", - "device.type.hue.desc": "Hue Entertainment API streaming", - "device.type.yeelight": "Yeelight", - "device.type.yeelight.desc": "Умная лампа / лента Xiaomi по LAN (один цвет, усреднённый по ленте)", - "device.yeelight.url": "IP-адрес:", - "device.yeelight.url.hint": "IP-адрес лампы Yeelight в локальной сети. TCP-порт 55443 фиксирован протоколом.", - "device.yeelight.url.placeholder": "192.168.1.50", - "device.yeelight_min_interval": "Мин. интервал обновления:", - "device.yeelight_min_interval.hint": "Локальный лимит частоты команд (мс). По умолчанию 500 мс держит лампу под ограничением ~1 команда/сек; меньшие значения могут вызвать троттлинг.", - "device.type.wiz": "WiZ", - "device.type.wiz.desc": "Лампа WiZ Connected (Philips) по UDP", - "device.wiz.url": "IP-адрес:", - "device.wiz.url.hint": "IP-адрес лампы WiZ в локальной сети. UDP-порт 38899 — по умолчанию.", - "device.wiz.url.placeholder": "192.168.1.50", - "device.wiz_min_interval": "Мин. интервал обновления:", - "device.wiz_min_interval.hint": "Локальный лимит частоты команд (мс). UDP fire-and-forget справляется с быстрыми обновлениями; по умолчанию 50 мс ≈ 20 Гц.", - "device.type.lifx": "LIFX", - "device.type.lifx.desc": "Умная лампа / лента LIFX по LAN", - "device.lifx.url": "IP-адрес:", - "device.lifx.url.hint": "IP-адрес лампы LIFX в локальной сети. UDP-порт 56700 — по умолчанию.", - "device.lifx.url.placeholder": "192.168.1.50", - "device.lifx_min_interval": "Мин. интервал обновления:", - "device.lifx_min_interval.hint": "Локальный лимит частоты команд (мс). LIFX рекомендует ≤20 команд/сек; по умолчанию 50 мс соответствует этому потолку.", - "device.type.govee": "Govee", - "device.type.govee.desc": "Лампа / комплект Govee Wi-Fi через LAN API", - "device.govee.url": "IP-адрес:", - "device.govee.url.hint": "IP-адрес устройства Govee в локальной сети. Сначала включите LAN Control в приложении Govee Home (Устройство → ⚙ → LAN Control), иначе лампа не ответит.", - "device.govee.url.placeholder": "192.168.1.50", - "device.govee_min_interval": "Мин. интервал обновления:", - "device.govee_min_interval.hint": "Локальный лимит частоты команд (мс). UDP fire-and-forget справляется с быстрыми обновлениями; по умолчанию 50 мс ≈ 20 Гц.", - "device.type.nanoleaf": "Nanoleaf", - "device.type.nanoleaf.desc": "Nanoleaf Light Panels / Canvas / Shapes / Lines / Elements (HTTP REST, требует сопряжения)", - "device.nanoleaf.url": "IP-адрес:", - "device.nanoleaf.url.hint": "IP-адрес контроллера Nanoleaf в локальной сети. HTTP-порт 16021 зафиксирован в протоколе.", - "device.nanoleaf.url.placeholder": "192.168.1.50", - "device.nanoleaf_min_interval": "Мин. интервал обновления:", - "device.nanoleaf_min_interval.hint": "Локальный лимит частоты команд (мс). По умолчанию 100 мс ≈ 10 Гц; накладные расходы HTTP ограничивают практический максимум ~20 Гц.", - "device.nanoleaf.pair.instructions": "Нажмите и удерживайте кнопку питания на контроллере Nanoleaf в течение 5 секунд, пока светодиоды не мигнут, затем нажмите «Начать». Контроллер откроет окно сопряжения на 30 секунд.", - "device.nanoleaf.pair_button": "Сопряжение", - "device.nanoleaf.token.label": "Токен авторизации:", - "device.nanoleaf.paired": "Сопряжено", - "device.type.ble": "BLE LED контроллер", - "device.type.ble.desc": "Bluetooth LE ленты: SP110E, Triones, Zengge, Govee (один цвет на всю ленту)", - "device.ble.url": "BLE адрес:", - "device.ble.url.hint": "MAC-адрес (Windows/Linux) или UUID (macOS) с префиксом ble://", - "device.ble.family": "Протокол:", - "device.ble.family.hint": "Какой BLE-протокол использует контроллер. Выбирайте по названию приложения, которым обычно управляете.", - "device.ble.family.sp110e.desc": "Адресуемые контроллеры — приложение LED Hue / SP110E", - "device.ble.family.triones.desc": "Одноцветные контроллеры — HappyLighting / LEDnet", - "device.ble.family.zengge.desc": "Одноцветные контроллеры — iLightsIn / Mohuan", - "device.ble.family.govee.desc": "Ленты Govee H6xxx — только без AES-шифрования", - "device.ble.govee_key": "Ключ AES Govee (hex):", - "device.ble.govee_key.hint": "Необязательно. Новая прошивка Govee требует AES-ключ под конкретную модель — оставьте пустым для старой прошивки.", - "device.ble.govee_key.placeholder": "32 символа hex, напр. 0102…1f20", - "device.type.usbhid": "USB HID", - "device.type.usbhid.desc": "USB RGB peripherals (keyboards, mice)", - "device.type.spi": "SPI Direct", - "device.type.spi.desc": "Raspberry Pi GPIO/SPI LED strips", - "device.type.chroma": "Razer Chroma", - "device.type.chroma.desc": "Razer peripherals via Chroma SDK", - "device.type.gamesense": "SteelSeries", - "device.type.gamesense.desc": "SteelSeries peripherals via GameSense", - "device.type.group": "Группа", - "device.type.group.desc": "Объединение нескольких устройств в одно виртуальное", - "device.group.children": "Дочерние устройства:", - "device.group.children.hint": "Выберите устройства для группы. Порядок важен в режиме последовательности.", - "device.group.add_child": "+ Добавить устройство", - "device.group.select_device": "Выберите устройство", - "device.group.mode": "Режим группы:", - "device.group.mode.hint": "Последовательный — LEDs соединяются друг за другом. Независимый — полная лента отражается на каждое устройство.", - "device.group.mode.sequence": "Последовательный", - "device.group.mode.sequence.desc": "Объединить светодиоды в одну длинную полосу", - "device.group.mode.independent": "Независимый", - "device.group.mode.independent.desc": "Дублировать полосу на каждое устройство отдельно", - "device.group.move_up": "Вверх", - "device.group.move_down": "Вниз", - "device.group.error.no_children": "Добавьте хотя бы одно устройство в группу.", - "device.chroma.device_type": "Peripheral Type:", - "device.chroma.device_type.hint": "Which Razer peripheral to control via Chroma SDK", - "device.gamesense.device_type": "Peripheral Type:", - "device.gamesense.device_type.hint": "Which SteelSeries peripheral to control via GameSense", - "device.espnow.peer_mac": "Peer MAC:", - "device.espnow.peer_mac.hint": "MAC address of the remote ESP32 receiver (e.g. AA:BB:CC:DD:EE:FF)", - "device.espnow.channel": "WiFi Channel:", - "device.espnow.channel.hint": "WiFi channel (1-14). Must match the receiver's channel.", - "device.hue.url": "Bridge IP:", - "device.hue.url.hint": "IP address of your Hue bridge", - "device.hue.username": "Bridge Username:", - "device.hue.username.hint": "Hue bridge application key from pairing", - "device.hue.client_key": "Client Key:", - "device.hue.client_key.hint": "Entertainment API client key (hex string from pairing)", - "device.hue.group_id": "Entertainment Group:", - "device.hue.group_id.hint": "Entertainment configuration ID from your Hue bridge", - "device.usbhid.url": "VID:PID:", - "device.usbhid.url.hint": "USB Vendor:Product ID in hex (e.g. 1532:0084)", - "device.spi.url": "GPIO/SPI Path:", - "device.spi.url.hint": "GPIO pin or SPI device path (e.g. spi://gpio:18)", - "device.spi.speed": "SPI Speed (Hz):", - "device.spi.speed.hint": "SPI clock speed. 800000 Hz for WS2812, 2400000 Hz for APA102.", - "device.spi.led_type": "LED Chipset:", - "device.spi.led_type.hint": "Type of addressable LED strip connected to the GPIO/SPI pin", - "device.spi.led_type.ws2812b.desc": "Most common, 800 KHz data, 3-wire RGB", - "device.spi.led_type.ws2812.desc": "Original WS2812, 800 KHz, 3-wire RGB", - "device.spi.led_type.ws2811.desc": "External driver IC, 400 KHz, 12V strips", - "device.spi.led_type.sk6812.desc": "Samsung LED, 800 KHz, 3-wire RGB", - "device.spi.led_type.sk6812_rgbw.desc": "SK6812 with dedicated white channel", - "device.gamesense.peripheral.keyboard": "Keyboard", - "device.gamesense.peripheral.keyboard.desc": "Per-key RGB illumination", - "device.gamesense.peripheral.mouse": "Mouse", - "device.gamesense.peripheral.mouse.desc": "Mouse RGB zones", - "device.gamesense.peripheral.headset": "Headset", - "device.gamesense.peripheral.headset.desc": "Headset earcup lighting", - "device.gamesense.peripheral.mousepad": "Mousepad", - "device.gamesense.peripheral.mousepad.desc": "Mousepad edge lighting zones", - "device.gamesense.peripheral.indicator": "Indicator", - "device.gamesense.peripheral.indicator.desc": "OLED/LED status indicator", - "device.dmx_protocol": "Протокол DMX:", - "device.dmx_protocol.hint": "Art-Net использует UDP порт 6454, sACN (E1.31) — UDP порт 5568", - "device.dmx_protocol.artnet.desc": "UDP unicast, порт 6454", - "device.dmx_protocol.sacn.desc": "Multicast/unicast, порт 5568", - "device.dmx_start_universe": "Начальный Universe:", - "device.dmx_start_universe.hint": "Первый DMX-юниверс (0-32767). Дополнительные юниверсы используются автоматически при >170 светодиодах.", - "device.dmx_start_channel": "Начальный канал:", - "device.dmx_start_channel.hint": "Первый DMX-канал в юниверсе (1-512)", - "device.dmx.url": "IP адрес:", - "device.dmx.url.hint": "IP адрес DMX-узла (напр. 192.168.1.50)", - "device.dmx.url.placeholder": "192.168.1.50", - "device.ddp.url": "IP-адрес:", - "device.ddp.url.hint": "Адрес DDP-приёмника. Порт по умолчанию — 4048.", - "device.ddp.url.placeholder": "192.168.1.50", - "device.ddp_port": "Порт DDP:", - "device.ddp_port.hint": "UDP-порт (0 = протокольный по умолчанию 4048).", - "device.ddp_destination_id": "ID назначения:", - "device.ddp_destination_id.hint": "Идентификатор назначения DDP (1 = дисплей).", - "device.ddp_color_order": "Порядок цветов:", - "device.ddp_color_order.hint": "Порядок байт каналов на линии. Большинство DDP-приёмников ожидают RGB.", - "device.ddp.color_order.rgb.desc": "Standard RGB byte order", - "device.ddp.color_order.grb.desc": "WS2812/WS2812B native order", - "device.ddp.color_order.brg.desc": "BRG byte order", - "device.ddp.color_order.rbg.desc": "RBG byte order", - "device.ddp.color_order.bgr.desc": "BGR byte order", - "device.ddp.color_order.gbr.desc": "GBR byte order", - "device.opc.url": "IP-адрес:", - "device.opc.url.hint": "Адрес приёмника OPC. TCP-порт по умолчанию 7890.", - "device.opc.url.placeholder": "192.168.1.50", - "device.opc_channel": "Канал:", - "device.opc_channel.hint": "Канал OPC (0 — широковещательная отправка на все каналы сервера, 1-255 — конкретный выход).", - "device.serial_port": "Серийный порт:", - "device.serial_port.hint": "Выберите COM порт устройства Adalight", - "device.serial_port.none": "Серийные порты не найдены", - "device.serial_port.select": "Выберите порт...", - "device.led_count_manual.hint": "Количество светодиодов на ленте (должно совпадать с вашим скетчем Arduino)", - "device.baud_rate": "Скорость порта:", - "device.baud_rate.hint": "Скорость серийного соединения. Выше = больше FPS, но требует соответствия скетчу Arduino.", - "device.led_type": "Тип LED:", - "device.led_type.hint": "RGB (3 канала) или RGBW (4 канала с выделенным белым)", - "device.send_latency": "Задержка отправки (мс):", - "device.send_latency.hint": "Имитация сетевой/серийной задержки на кадр в миллисекундах", - "device.css_processing_template": "Шаблон Обработки Полос:", - "device.css_processing_template.hint": "Шаблон обработки по умолчанию, применяемый ко всем цветовым полосам на этом устройстве", - "device.mqtt_topic": "MQTT Топик:", - "device.mqtt_topic.hint": "MQTT топик для публикации пиксельных данных (напр. mqtt://ledgrab/device/name)", - "device.mqtt_topic.placeholder": "mqtt://ledgrab/device/гостиная", - "device.mqtt_source": "MQTT Брокер:", - "device.mqtt_source.hint": "К какому MQTT-брокеру публикует это устройство. Брокеры настраиваются в разделе Интеграции → Источники MQTT. Оставьте пустым, чтобы использовать первый доступный брокер.", - "device.mqtt_source.none": "— Первый доступный брокер", - "device.ws_url": "URL подключения:", - "device.ws_url.hint": "WebSocket URL для подключения клиентов и получения LED данных", - "device.openrgb.url": "OpenRGB URL:", - "device.openrgb.url.hint": "Адрес сервера OpenRGB (напр. openrgb://localhost:6742/0)", - "device.openrgb.zone": "Зоны:", - "device.openrgb.zone.hint": "Выберите зоны LED для управления (оставьте все неотмеченными для всех зон)", - "device.openrgb.zone.loading": "Загрузка зон…", - "device.openrgb.zone.error": "Не удалось загрузить зоны", - "device.openrgb.mode": "Режим зон:", - "device.openrgb.mode.hint": "Объединённый — все зоны как одна непрерывная LED-лента. Раздельный — каждая зона независимо отображает полный эффект.", - "device.openrgb.mode.combined": "Объединённая лента", - "device.openrgb.mode.separate": "Независимые зоны", - "device.openrgb.added_multiple": "Добавлено {count} устройств", - "device.url.hint": "IP адрес или имя хоста устройства (напр. http://192.168.1.100)", - "device.name": "Имя Устройства:", - "device.name.placeholder": "ТВ в Гостиной", - "device.url": "URL:", - "device.url.placeholder": "http://192.168.1.100", - "device.led_count": "Количество Светодиодов:", - "dashboard.device.chip": "Чип:", - "device.led_count.hint": "Количество светодиодов, настроенных в устройстве", - "device.led_count.hint.auto": "Автоматически определяется из устройства", - "device.button.add": "Добавить Устройство", - "device.button.start": "Запустить", - "device.button.stop": "Остановить", - "device.button.settings": "Основные настройки", - "device.button.capture_settings": "Настройки захвата", - "device.button.calibrate": "Калибровка", - "device.button.remove": "Удалить", - "device.button.webui": "Открыть веб-интерфейс устройства", - "device.button.power_off": "Выключить", - "device.button.ping": "Пинг устройства", - "device.ping.online": "Онлайн ({ms}мс)", - "device.ping.offline": "Устройство недоступно", - "device.ping.error": "Ошибка пинга", - "device.power.off_success": "Устройство выключено", - "device.status.connected": "Подключено", - "device.status.disconnected": "Отключено", - "device.status.error": "Ошибка", - "device.status.processing": "Обработка", - "device.status.idle": "Ожидание", - "device.fps": "FPS:", - "device.display": "Дисплей:", - "device.remove.confirm": "Вы уверены, что хотите удалить это устройство?", - "device.added": "Устройство успешно добавлено", - "device.removed": "Устройство удалено", - "device.started": "Обработка запущена", - "device.stopped": "Обработка остановлена", - "device.metrics.actual_fps": "Факт. FPS", - "device.metrics.current_fps": "Текущ. FPS", - "device.metrics.target_fps": "Целев. FPS", - "device.metrics.potential_fps": "Потенц. FPS", - "device.metrics.frames": "Кадры", - "device.metrics.frames_skipped": "Пропущено", - "device.metrics.keepalive": "Keepalive", - "device.metrics.errors": "Ошибки", - "device.metrics.uptime": "Время работы", - "device.metrics.timing": "Тайминг пайплайна:", - "device.metrics.device_fps": "Частота обновления устройства", - "device.health.online": "Онлайн", - "device.health.offline": "Недоступен", - "device.health.streaming_unreachable": "Недоступен во время стриминга", - "device.health.checking": "Проверка...", - "patch.streaming": "ТРАНСЛЯЦИЯ", - "patch.standby": "ОЖИДАНИЕ", - "patch.online": "ОНЛАЙН", - "patch.offline": "ОФЛАЙН", - "patch.disconnected": "ОТКЛЮЧЕНО", - "patch.not_configured": "НЕ НАСТРОЕНО", - "patch.unreachable": "НЕДОСТУПЕН", - "patch.checking": "ПРОВЕРКА", - "patch.ticking": "ИДЁТ", - "patch.paused": "ПАУЗА", - "patch.patched": "ПОДКЛЮЧЕНО", - "patch.preset": "ПРЕСЕТ", - "patch.source": "ИСТОЧНИК", - "patch.template": "ШАБЛОН", - "patch.pipeline": "КОНВЕЙЕР", - "patch.strip": "ПОЛОСА", - "patch.value": "ЗНАЧЕНИЕ", - "patch.ready": "ГОТОВ", - "patch.polling": "ОПРОС", - "perf.idle": "простой", - "perf.offline": "офлайн", - "perf.online": "онлайн", - "perf.no_devices": "нет устройств", - "perf.all_online": "все онлайн", - "perf.online_count": "{count} онлайн", - "perf.offline_count": "{count} офлайн", - "perf.total_bytes": "всего {bytes}", - "perf.max_ms": "макс {ms}мс", - "perf.targets_count.one": "{count} цель", - "perf.targets_count.few": "{count} цели", - "perf.targets_count.many": "{count} целей", - "perf.no_captures": "нет источников", - "perf.captures_count.one": "{count} источник", - "perf.captures_count.few": "{count} источника", - "perf.captures_count.many": "{count} источников", - "perf.ratio_of_requested": "{percent}% от запрошенного · {captures}", - "perf.total_count": "{count} всего", - "perf.skipped_per_sec": "{rate} пропущ/с", - "perf.tip.now": "сейчас", - "perf.tip.ago": "−{seconds}с", - "device.last_seen.label": "Последний раз", - "device.last_seen.just_now": "только что", - "device.last_seen.seconds": "%d с назад", - "device.last_seen.minutes": "%d мин назад", - "device.last_seen.hours": "%d ч назад", - "device.last_seen.days": "%d д назад", - "device.tutorial.start": "Начать обучение", - "device.tip.identity": "Идентификация устройства — имя, тип и индикатор онлайн/офлайн.", - "device.tip.metadata": "Адрес устройства и версия прошивки. Кликните по URL, чтобы открыть веб-интерфейс устройства.", - "device.tip.brightness": "Перетащите для регулировки яркости. Изменения применяются мгновенно.", - "device.brightness": "Яркость", - "device.tip.ping": "Пинг устройства — обновляет статус и измеряет задержку.", - "device.tip.settings": "Открыть настройки устройства — имя, URL, возможности и проверки соединения.", - "device.tip.menu": "Дополнительные действия — дублировать, скрыть или удалить устройство.", - "device.tip.add": "Нажмите, чтобы добавить новое LED устройство", - "settings.title": "Настройки", - "settings.tab.general": "Основные", - "settings.tab.backup": "Бэкап", - "settings.tab.mqtt": "MQTT", - "settings.tab.appearance": "Оформление", - "settings.logs.open_viewer": "Открыть логи", - "settings.external_url.label": "Внешний URL", - "settings.external_url.hint": "Если указан, этот базовый URL используется в URL-ах вебхуков и других пользовательских ссылках вместо автоопределённого локального IP. Пример: https://myserver.example.com:8080", - "settings.external_url.placeholder": "https://myserver.example.com:8080", - "settings.external_url.save": "Сохранить", - "settings.external_url.saved": "Внешний URL сохранён", - "settings.external_url.save_error": "Не удалось сохранить внешний URL", - "settings.general.title": "Основные Настройки", - "settings.section.identity": "Идентификация", - "settings.section.connection": "Подключение", - "settings.section.hardware": "Оборудование", - "settings.section.behavior": "Поведение", - "settings.section.provider": "Провайдер", - "settings.section.refresh": "Обновление", - "settings.section.filters": "Фильтры", - "settings.section.routing": "Маршрутизация", - "settings.section.output": "Вывод", - "settings.section.filtering": "Фильтрация", - "settings.section.broker": "Брокер", - "settings.section.protocol": "Протокол", - "settings.section.adapter": "Адаптер", - "settings.section.mappings": "Сопоставления", - "settings.section.diagnostics": "Диагностика", - "settings.section.source": "Источник", - "settings.section.engine": "Движок", - "settings.section.timing": "Тайминг", - "settings.section.gradient": "Градиент", - "settings.section.layout": "Раскладка", - "settings.section.strip": "Лента", - "settings.section.type": "Тип", - "settings.section.notes": "Заметки", - "settings.section.file": "Файл", - "settings.section.auth": "Авторизация", - "settings.section.configure": "Настройка", - "settings.section.restart": "Перезапуск", - "settings.section.loopback": "Локальный доступ", - "settings.section.test_setup": "Параметры теста", - "settings.section.offsets": "Смещения", - "settings.section.line_properties": "Параметры линии", - "settings.section.test": "Тест", - "settings.section.preview": "Превью", - "settings.section.controls": "Управление", - "settings.section.history": "История", - "ha_source.use_ssl.hint": "Включите для HTTPS / wss соединений с Home Assistant", - "game_integration.enabled.hint": "Отключённые интеграции перестают опрашивать и генерировать события", - "settings.capture.title": "Настройки Захвата", - "settings.capture.saved": "Настройки захвата обновлены", - "settings.capture.failed": "Не удалось сохранить настройки захвата", - "settings.brightness": "Яркость:", - "settings.brightness.hint": "Общая яркость для этого устройства (0-100%)", - "settings.url.hint": "IP адрес или имя хоста устройства", - "settings.display_index": "Дисплей:", - "settings.display_index.hint": "Какой экран захватывать для этого устройства", - "settings.fps": "Целевой FPS:", - "settings.fps.hint": "Целевая частота кадров (10-90)", - "settings.capture_template": "Шаблон Движка:", - "settings.capture_template.hint": "Движок захвата экрана и конфигурация для этого устройства", - "settings.button.cancel": "Отмена", - "settings.health_interval": "Интервал Проверки (с):", - "settings.health_interval.hint": "Как часто проверять статус устройства (5-600 секунд)", - "settings.auto_shutdown": "Авто-восстановление:", - "settings.auto_shutdown.hint": "Восстанавливать устройство в режим ожидания при остановке целей или сервера", - "settings.button.save": "Сохранить Изменения", - "settings.saved": "Настройки успешно сохранены", - "settings.failed": "Не удалось сохранить настройки", - "calibration.title": "Калибровка Светодиодов", - "calibration.tip.led_count": "Укажите количество LED на каждой стороне", - "calibration.tip.start_corner": "Нажмите на угол для выбора стартовой позиции", - "calibration.tip.direction": "Переключение направления ленты (по часовой / против часовой)", - "calibration.tip.offset": "Смещение LED — расстояние от LED 0 до стартового угла", - "calibration.tip.span": "Перетащите зелёные полосы для настройки зоны покрытия", - "calibration.tip.test": "Нажмите на край для теста LED", - "calibration.tip.overlay": "Включите оверлей для отображения позиций и нумерации LED на мониторе", - "calibration.tip.toggle_inputs": "Нажмите на общее количество LED для скрытия боковых полей", - "calibration.tip.border_width": "Сколько пикселей от края экрана использовать для цветов LED", - "calibration.tip.skip_leds_start": "Пропуск LED в начале ленты — пропущенные LED остаются выключенными", - "calibration.tip.skip_leds_end": "Пропуск LED в конце ленты — пропущенные LED остаются выключенными", - "tour.welcome": "Добро пожаловать в LED Grab! Этот краткий тур познакомит вас с интерфейсом. Используйте стрелки или кнопки для навигации.", - "tour.dashboard": "Дашборд — обзор запущенных целей, автоматизаций и состояния устройств.", - "tour.targets": "Цели — добавляйте WLED-устройства, настраивайте LED-цели с захватом и калибровкой.", - "tour.sources": "Источники — управление шаблонами захвата, источниками изображений, звука и цветовых полос.", - "tour.integrations": "Интеграции — подключайте внешние сервисы: погоду, Home Assistant, MQTT и игровые интеграции.", - "tour.graph": "Граф — визуальный обзор всех сущностей и их связей. Перетаскивайте порты для соединения, правый клик по связям для отключения.", - "tour.automations": "Автоматизации — автоматизируйте переключение сцен по расписанию, звуку или значениям.", - "tour.settings": "Настройки — резервное копирование и восстановление конфигурации.", - "tour.api": "API Документация — интерактивная документация REST API на базе Swagger.", - "tour.search": "Поиск — быстрый поиск и переход к любому объекту по Ctrl+K.", - "tour.theme": "Тема — переключение между тёмной и светлой темой.", - "tour.accent": "Цвет акцента — настройте цвет интерфейса по своему вкусу.", - "tour.language": "Язык — выберите предпочитаемый язык интерфейса.", - "tour.restart": "Запустить тур заново", - "tour.dash.perf": "Производительность — графики FPS в реальном времени, метрики задержки и интервал опроса.", - "tour.dash.targets": "Каналы — все ваши цели в одном месте, разделённые на запущенные и остановленные.", - "tour.dash.running": "Запущенные цели — метрики стриминга и быстрая остановка.", - "tour.dash.stopped": "Остановленные цели — готовы к запуску одним нажатием.", - "tour.dash.automations": "Автоматизации — статус активных автоматизаций и быстрое включение/выключение.", - "tour.dash.scenes": "Пресеты сцен — сохранённые состояния системы, применяются одним кликом.", - "tour.dash.sync_clocks": "Синхро-часы — общие таймеры для синхронизации анимаций между источниками.", - "tour.dash.integrations": "Интеграции — статус подключения для Weather, Home Assistant и MQTT.", - "tour.dash.customize_btn": "Кнопка «Настроить» — открывает боковую панель, где можно изменить порядок секций, скрыть их или подстроить раскладку дашборда.", - "tour.dash.customize_panel": "Настройка дашборда — центр управления раскладкой. Изменения применяются мгновенно; закрытие — Esc или кнопка ×.", - "tour.dash.customize_presets": "Пресеты — применяйте готовые раскладки (компактная, фокус, полная) одним кликом. Выбор пресета сбрасывает ручные настройки.", - "tour.dash.customize_global": "Глобальные настройки — ширина контента, уровень анимаций, режим производительности (system/app/both) и окно выборки графиков.", - "tour.dash.customize_sections": "Секции — перетаскивайте для смены порядка; «глаз» скрывает; «шеврон» делает секцию свёрнутой по умолчанию. Также для каждой секции можно выбрать плотность.", - "tour.dash.customize_perf_cells": "Ячейки производительности — выбирайте, какие метрики отображать в секции Performance, настраивайте ширину, окно выборки и шкалу Y.", - "tour.tgt.nav": "Используйте этот выпадающий список для переключения между типами целей: LED-устройства, LED-цели и лампы Home Assistant.", - "tour.tgt.devices": "Устройства — ваши LED-контроллеры, найденные в сети.", - "tour.tgt.css": "Цветовые полосы — определите, как области экрана соответствуют сегментам LED.", - "tour.tgt.targets": "LED-цели — объедините устройство, цветовую полосу и источник захвата для стриминга.", - "tour.tgt.ha_light_targets": "Цели HA-ламп — выберите лампу Home Assistant и источник цвета/значения для управления.", - "tour.src.nav": "Используйте этот выпадающий список для переключения между типами источников — изображения, цветовые полосы, аудио, значения, синхронизация, ассеты.", - "tour.src.raw": "Raw — источники захвата экрана с ваших дисплеев.", - "tour.src.templates": "Шаблоны захвата — переиспользуемые конфигурации (разрешение, FPS, обрезка).", - "tour.src.static": "Статичные изображения — тестируйте настройку с файлами изображений.", - "tour.src.processed": "Обработка — применяйте эффекты: размытие, яркость, цветокоррекция.", - "tour.src.color_strip": "Цветовые полосы — определяют, как области экрана сопоставляются с LED-сегментами.", - "tour.src.audio": "Аудио — захват микрофона или системного звука и обработка через шаблоны для реактивных эффектов.", - "tour.src.value": "Источники значений — динамические числа или цвета, управляемые временем суток, аудио, системными метриками, сущностями Home Assistant, градиентами или расписаниями. Используются для анимации эффектов, модуляции цветовых полос и триггеров автоматизаций.", - "tour.src.sync": "Синхро-часы — общие таймеры для синхронизации анимаций между несколькими источниками.", - "tour.auto.list": "Автоматизации — автоматизируйте активацию сцен по времени, звуку или значениям.", - "tour.auto.add": "Нажмите + для создания новой автоматизации с условиями и сценой для активации.", - "tour.auto.card": "Каждая карточка показывает статус автоматизации, условия и кнопки управления.", - "tour.auto.scenes_list": "Сцены — сохранённые состояния системы, которые автоматизации могут активировать или вы можете применить вручную.", - "tour.auto.scenes_add": "Нажмите + для захвата текущего состояния системы как нового пресета сцены.", - "tour.auto.scenes_card": "Каждая карточка сцены показывает количество целей/устройств. Нажмите для редактирования, перезахвата или активации.", - "tour.int.nav": "Используйте этот выпадающий список для переключения между типами интеграций: погода, Home Assistant, MQTT и игровые интеграции.", - "tour.int.weather": "Погода — получайте данные о погоде (температура, условия, прогноз) в виде источников значений для эффектов от окружающей среды.", - "tour.int.home_assistant": "Home Assistant — подключение к экземпляру HA. Лампы, датчики и сущности становятся источниками значений и целями вывода.", - "tour.int.mqtt": "MQTT — подписывайтесь на топики брокера для передачи данных внешних датчиков, автоматизаций или удалённых триггеров в LedGrab.", - "tour.int.game": "Игры — реакция на игровые события (Minecraft, Hammer of God и др.) с активацией сцен и цветовых эффектов на основе состояния игры.", - "calibration.tutorial.start": "Начать обучение", - "calibration.overlay_toggle": "Оверлей", - "calibration.start_position": "Начальная Позиция:", - "calibration.position.bottom_left": "Нижний Левый", - "calibration.position.bottom_right": "Нижний Правый", - "calibration.position.top_left": "Верхний Левый", - "calibration.position.top_right": "Верхний Правый", - "calibration.direction": "Направление:", - "calibration.direction.clockwise": "По Часовой Стрелке", - "calibration.direction.counterclockwise": "Против Часовой Стрелки", - "calibration.leds.top": "Светодиодов Сверху:", - "calibration.leds.right": "Светодиодов Справа:", - "calibration.leds.bottom": "Светодиодов Снизу:", - "calibration.leds.left": "Светодиодов Слева:", - "calibration.offset": "Смещение LED:", - "calibration.offset.hint": "Расстояние от физического LED 0 до стартового угла (по направлению ленты)", - "calibration.skip_start": "Пропуск LED (начало):", - "calibration.skip_start.hint": "Количество LED, которые будут выключены в начале ленты (0 = нет)", - "calibration.skip_end": "Пропуск LED (конец):", - "calibration.skip_end.hint": "Количество LED, которые будут выключены в конце ленты (0 = нет)", - "calibration.border_width": "Граница (px):", - "calibration.roi": "Область захвата (%):", - "calibration.roi.hint": "Брать пиксели только из этого прямоугольника экрана, чтобы панель задач, интерфейс игры или чёрные полосы не искажали цвета краёв. Весь экран = X/Y 0, Ширина/Высота 100.", - "calibration.roi.x": "X (%)", - "calibration.roi.y": "Y (%)", - "calibration.roi.width": "Ширина (%)", - "calibration.roi.height": "Высота (%)", - "calibration.border_width.hint": "Сколько пикселей от края экрана выбирать для цвета LED (1-100)", - "calibration.button.cancel": "Отмена", - "calibration.button.save": "Сохранить", - "calibration.saved": "Калибровка сохранена", - "calibration.failed": "Не удалось сохранить калибровку", - "server.healthy": "Сервер онлайн", - "server.offline": "Сервер офлайн", - "error.unauthorized": "Не авторизован - пожалуйста, войдите", - "error.network": "Сетевая ошибка", - "error.unknown": "Произошла ошибка", - "modal.discard_changes": "У вас есть несохранённые изменения. Отменить их?", - "confirm.title": "Подтверждение", - "confirm.yes": "Да", - "confirm.no": "Нет", - "confirm.stop_all": "Остановить все запущенные цели?", - "confirm.turn_off_device": "Выключить это устройство?", - "common.loading": "Загрузка...", - "common.delete": "Удалить", - "common.remove": "Убрать", - "common.edit": "Редактировать", - "common.clone": "Клонировать", - "common.hide": "Скрыть карточку", - "common.more_actions": "Ещё действия", - "common.card_color": "Цвет карточки", - "common.none": "Нет", - "common.none_no_cspt": "Нет (без шаблона обработки)", - "common.none_no_input": "Нет (без источника)", - "common.none_own_speed": "Нет (своя скорость)", - "common.undo": "Отменить", - "common.cancel": "Отмена", - "common.back": "Назад", - "common.apply": "Применить", - "common.start": "ПУСК", - "common.stop": "СТОП", - "common.led_preview": "Превью LED", - "device.icon.eyebrow": "Иконка карточки", - "device.icon.title": "Выберите иконку", - "device.icon.for": "для", - "device.icon.choose": "Выбрать иконку…", - "device.icon.change": "Изменить иконку…", - "device.icon.remove": "Удалить иконку", - "device.icon.search.placeholder": "Поиск иконок…", - "device.icon.color_toggle": "Цвет", - "device.icon.recent": "Недавние", - "device.icon.empty": "Иконки не найдены.", - "device.icon.hint": "↵ Применить · Esc Отмена", - "device.icon.saved": "Иконка сохранена", - "device.icon.error.save_failed": "Не удалось сохранить иконку", - "device.icon.cat.all": "Все", - "device.icon.cat.hardware": "Оборудование", - "device.icon.cat.lighting": "Освещение", - "device.icon.cat.rooms": "Комнаты", - "device.icon.cat.media": "Медиа", - "device.icon.cat.signal": "Сигнал", - "device.icon.cat.ambience": "Атмосфера", - "device.icon.cat.weather": "Погода", - "device.icon.cat.nature": "Природа", - "device.icon.cat.controls": "Управление", - "device.icon.cat.status": "Состояние", - "device.icon.cat.office": "Офис", - "device.icon.phone": "Телефон", - "device.icon.package": "Устройство", - "device.icon.code": "Код", - "device.icon.laptop": "Ноутбук", - "device.icon.server": "Сервер", - "device.icon.router": "Маршрутизатор", - "device.icon.webcam": "Веб-камера", - "device.icon.bot": "Бот", - "device.icon.watch": "Умные часы", - "device.icon.mcu": "Микроконтроллер", - "device.icon.dim": "Приглушённый свет", - "device.icon.ring": "Кольцевая лампа", - "device.icon.point": "Точечный свет", - "device.icon.ceiling": "Потолочный светильник", - "device.icon.desk_lamp": "Настольная лампа", - "device.icon.wall_light": "Настенный светильник", - "device.icon.flashlight": "Фонарик", - "device.icon.bulb_off": "Лампа выключена", - "device.icon.candle": "Свеча", - "device.icon.kitchen": "Кухня", - "device.icon.bath": "Ванная", - "device.icon.garage": "Гараж", - "device.icon.outdoor": "Улица", - "device.icon.projector": "Проектор", - "device.icon.camcorder": "Видеокамера", - "device.icon.disc": "Диск", - "device.icon.image": "Изображение", - "device.icon.audio_file": "Аудиофайл", - "device.icon.send": "Отправить", - "device.icon.rain": "Дождь", - "device.icon.snow": "Снег", - "device.icon.thunder": "Гроза", - "device.icon.fog": "Туман", - "device.icon.wind": "Ветер", - "device.icon.snowflake": "Снежинка", - "device.icon.umbrella": "Зонт", - "device.icon.sunrise": "Восход", - "device.icon.sunset": "Закат", - "device.icon.tree": "Дерево", - "device.icon.flower": "Цветок", - "device.icon.mountain": "Гора", - "device.icon.waves": "Волны", - "device.icon.sprout": "Росток", - "device.icon.water_drops": "Капли воды", - "device.icon.leaf": "Лист", - "device.icon.switch": "Переключатель", - "device.icon.slider": "Слайдер", - "device.icon.settings": "Настройки", - "device.icon.refresh": "Обновить", - "device.icon.undo": "Отменить", - "device.icon.trash": "Корзина", - "device.icon.link": "Ссылка", - "device.icon.search": "Поиск", - "device.icon.add": "Добавить", - "device.icon.show": "Показать", - "device.icon.lock": "Замок", - "device.icon.key": "Ключ", - "device.icon.tool": "Инструмент", - "device.icon.check": "Галочка", - "device.icon.ok": "ОК", - "device.icon.warning": "Внимание", - "device.icon.help": "Справка", - "device.icon.off": "Отключено", - "device.icon.shield": "Щит", - "device.icon.target": "Цель", - "device.icon.pulse": "Пульс", - "device.icon.trend": "Тренд", - "device.icon.battery": "Батарея", - "device.icon.calendar": "Календарь", - "device.icon.mail": "Почта", - "device.icon.coffee": "Кофе", - "device.icon.briefcase": "Портфель", - "device.icon.doc": "Документ", - "device.icon.checklist": "Список", - "device.icon.hashtag": "Хэштег", - "device.icon.clock": "Часы", - "device.icon.entity.device": "Устройство", - "device.icon.entity.target": "LED-цель", - "device.icon.entity.ha_light_target": "HA-светильник", - "device.icon.entity.picture_source": "Источник изображения", - "device.icon.entity.audio_source": "Источник аудио", - "device.icon.entity.weather_source": "Источник погоды", - "device.icon.entity.value_source": "Источник значения", - "device.icon.entity.mqtt_source": "Источник MQTT", - "device.icon.entity.ha_source": "Источник Home Assistant", - "device.icon.entity.automation": "Автоматизация", - "device.icon.entity.scene_preset": "Сцена", - "device.icon.entity.scene_playlist": "Плейлист", - "device.icon.entity.sync_clock": "Часы синхронизации", - "device.icon.entity.game_integration": "Игровая интеграция", - "device.icon.entity.audio_processing_template": "Шаблон обработки аудио", - "device.icon.entity.pattern_template": "Шаблон паттерна", - "device.icon.entity.capture_template": "Шаблон захвата", - "device.icon.entity.pp_template": "Шаблон постобработки", - "device.icon.entity.cspt": "Шаблон обработки полоски", - "device.icon.entity.audio_template": "Аудиошаблон", - "device.icon.entity.gradient": "Градиент", - "device.icon.entity.color_strip_source": "Цветная полоска", - "device.icon.entity.asset": "Ассет", - "device.icon.inherited_from": "Унаследовано от %s", - "device.icon.override_inherited": "Заменить унаследованную иконку…", - "device.icon.use_inherited": "Использовать унаследованную", - "validation.required": "Обязательное поле", - "bulk.processing": "Обработка…", - "api.error.timeout": "Превышено время ожидания — попробуйте снова", - "api.error.network": "Ошибка сети — проверьте подключение", - "palette.search": "Поиск…", - "section.filter.placeholder": "Фильтр...", - "section.filter.reset": "Очистить фильтр", - "tags.label": "Теги", - "tags.hint": "Назначьте теги для группировки и фильтрации карточек", - "tags.placeholder": "Добавить тег...", - "section.expand_all": "Развернуть все секции", - "section.collapse_all": "Свернуть все секции", - "streams.title": "Входы", - "integrations.title": "Интеграции", - "streams.description": "Источники определяют конвейер захвата. Сырой источник захватывает экран с помощью шаблона захвата. Обработанный источник применяет постобработку к другому источнику. Назначайте источники устройствам.", - "streams.group.raw": "Источники", - "streams.group.raw_templates": "Шаблоны движка", - "streams.group.processed": "Источники", - "streams.group.proc_templates": "Шаблоны фильтров", - "streams.group.css_processing": "Шаблоны Обработки", - "streams.group.color_strip": "Цветовые Полосы", - "streams.group.audio": "Аудио", - "streams.group.audio_templates": "Аудио шаблоны", - "streams.section.streams": "Источники", - "streams.add": "Добавить Источник", - "streams.add.raw": "Добавить Захват Экрана", - "streams.add.processed": "Добавить Обработанный", - "streams.edit": "Редактировать Источник", - "streams.edit.raw": "Редактировать Захват Экрана", - "streams.edit.processed": "Редактировать Обработанный Источник", - "streams.name": "Имя Источника:", - "streams.name.placeholder": "Мой Источник", - "streams.type": "Тип:", - "streams.type.raw": "Захват экрана", - "streams.type.processed": "Обработанный", - "streams.display": "Дисплей:", - "streams.display.hint": "Какой экран захватывать", - "streams.capture_template": "Шаблон Движка:", - "streams.capture_template.hint": "Шаблон движка, определяющий способ захвата экрана", - "streams.target_fps": "Целевой FPS:", - "streams.target_fps.hint": "Целевое количество кадров в секунду (1-90)", - "streams.source": "Источник:", - "streams.source.hint": "Источник, к которому применяются фильтры обработки", - "streams.pp_template": "Шаблон Фильтра:", - "streams.pp_template.hint": "Шаблон фильтра для применения к источнику", - "streams.description_label": "Описание (необязательно):", - "streams.description_placeholder": "Опишите этот источник...", - "streams.created": "Источник успешно создан", - "streams.updated": "Источник успешно обновлён", - "streams.deleted": "Источник успешно удалён", - "streams.delete.confirm": "Вы уверены, что хотите удалить этот источник?", - "streams.modal.loading": "Загрузка...", - "streams.error.load": "Не удалось загрузить источники", - "streams.error.required": "Пожалуйста, заполните все обязательные поля", - "streams.error.delete": "Не удалось удалить источник", - "streams.test.title": "Тест Источника", - "streams.test.run": "Запустить", - "streams.test.running": "Тестирование источника...", - "streams.test.duration": "Длительность Захвата (с):", - "streams.test.error.failed": "Тест источника не удался", - "postprocessing.title": "Шаблоны Фильтров", - "postprocessing.description": "Шаблоны обработки определяют фильтры изображений и цветокоррекцию. Назначайте их обработанным источникам для единообразной постобработки на всех устройствах.", - "postprocessing.add": "Добавить Шаблон Фильтра", - "postprocessing.edit": "Редактировать Шаблон Фильтра", - "postprocessing.name": "Имя Шаблона:", - "postprocessing.name.placeholder": "Мой Шаблон Фильтра", - "filters.select_type": "Выберите тип фильтра...", - "filters.add": "Добавить фильтр", - "filters.remove": "Удалить", - "filters.drag_to_reorder": "Перетащите для изменения порядка", - "filters.empty": "Фильтры не добавлены. Используйте селектор ниже для добавления.", - "filters.brightness": "Яркость", - "filters.brightness.desc": "Регулировка общей яркости изображения", - "filters.saturation": "Насыщенность", - "filters.saturation.desc": "Усиление или снижение интенсивности цвета", - "filters.gamma": "Гамма", - "filters.gamma.desc": "Нелинейная коррекция кривой яркости", - "filters.downscaler": "Уменьшение", - "filters.downscaler.desc": "Снижение разрешения для быстрой обработки", - "filters.pixelate": "Пикселизация", - "filters.pixelate.desc": "Мозаичное усреднение блоков", - "filters.auto_crop": "Авто Обрезка", - "filters.auto_crop.desc": "Удаление чёрных полос из леттербокса", - "filters.flip": "Отражение", - "filters.flip.desc": "Зеркальное отражение по горизонтали или вертикали", - "filters.color_correction": "Цветокоррекция", - "filters.color_correction.desc": "Баланс белого и цветовая температура", - "filters.filter_template": "Шаблон фильтров", - "filters.filter_template.desc": "Встроить другой шаблон обработки", - "filters.css_filter_template": "Шаблон Фильтра Полос", - "filters.css_filter_template.desc": "Встроить другой шаблон обработки полос", - "filters.frame_interpolation": "Интерполяция кадров", - "filters.frame_interpolation.desc": "Сглаживание между кадрами", - "filters.noise_gate": "Шумоподавление", - "filters.noise_gate.desc": "Подавление малых изменений цвета ниже порога", - "filters.palette_quantization": "Квантизация палитры", - "filters.palette_quantization.desc": "Сокращение цветов до ограниченной палитры", - "filters.reverse": "Реверс", - "filters.reverse.desc": "Изменить порядок светодиодов на обратный", - "postprocessing.description_label": "Описание (необязательно):", - "postprocessing.description_placeholder": "Опишите этот шаблон...", - "postprocessing.created": "Шаблон успешно создан", - "postprocessing.updated": "Шаблон успешно обновлён", - "postprocessing.deleted": "Шаблон успешно удалён", - "postprocessing.delete.confirm": "Вы уверены, что хотите удалить этот шаблон фильтра?", - "postprocessing.error.load": "Не удалось загрузить шаблоны фильтров", - "postprocessing.error.required": "Пожалуйста, заполните все обязательные поля", - "postprocessing.error.delete": "Не удалось удалить шаблон фильтра", - "postprocessing.config.show": "Показать настройки", - "postprocessing.test.title": "Тест шаблона фильтра", - "postprocessing.test.source_stream": "Источник:", - "postprocessing.test.running": "Тестирование шаблона фильтра...", - "postprocessing.test.error.no_stream": "Пожалуйста, выберите источник", - "postprocessing.test.error.failed": "Тест шаблона фильтра не удался", - "css_processing.title": "Шаблоны Обработки Полос", - "css_processing.add": "Добавить Шаблон Обработки Полос", - "css_processing.edit": "Редактировать Шаблон Обработки Полос", - "css_processing.name": "Имя Шаблона:", - "css_processing.name_placeholder": "Мой Шаблон Обработки Полос", - "css_processing.description_label": "Описание (необязательно):", - "css_processing.description_placeholder": "Опишите этот шаблон...", - "css_processing.created": "Шаблон обработки полос создан", - "css_processing.updated": "Шаблон обработки полос обновлён", - "css_processing.deleted": "Шаблон обработки полос удалён", - "css_processing.delete.confirm": "Вы уверены, что хотите удалить этот шаблон обработки полос?", - "css_processing.error.required": "Заполните все обязательные поля", - "css_processing.error.load": "Ошибка загрузки шаблона обработки полос", - "css_processing.error.delete": "Ошибка удаления шаблона обработки полос", - "css_processing.error.clone_failed": "Не удалось клонировать шаблон обработки полос", - "device.button.stream_selector": "Настройки источника", - "device.stream_settings.title": "Настройки источника", - "device.stream_selector.label": "Источник:", - "device.stream_selector.hint": "Выберите источник, определяющий что это устройство захватывает и обрабатывает", - "device.stream_selector.none": "-- Источник не назначен --", - "device.stream_selector.saved": "Настройки источника обновлены", - "device.stream_settings.border_width": "Ширина границы (px):", - "device.stream_settings.border_width_hint": "Сколько пикселей от края экрана выбирать для цвета LED (1-100)", - "device.stream_settings.interpolation": "Режим интерполяции:", - "device.stream_settings.interpolation.average": "Среднее", - "device.stream_settings.interpolation.median": "Медиана", - "device.stream_settings.interpolation.dominant": "Доминантный", - "device.stream_settings.interpolation_hint": "Как вычислять цвет LED из выбранных пикселей", - "device.stream_settings.smoothing": "Сглаживание:", - "device.stream_settings.smoothing_hint": "Временное смешивание между кадрами (0=нет, 1=полное). Уменьшает мерцание.", - "device.tip.stream_selector": "Настройки источника и проекции LED для этого устройства", - "streams.group.static_image": "Статические", - "streams.add.static_image": "Добавить статическое изображение (источник)", - "streams.edit.static_image": "Редактировать статическое изображение (источник)", - "streams.type.static_image": "Статическое изображение", - "streams.group.video": "Видео", - "streams.add.video": "Добавить видеоисточник", - "streams.edit.video": "Редактировать видеоисточник", - "picture_source.type.video": "Видео", - "picture_source.type.video.desc": "Потоковые кадры из загруженного видео", - "picture_source.video.loop": "Зацикливание:", - "picture_source.video.speed": "Скорость воспроизведения:", - "picture_source.video.start_time": "Время начала (с):", - "picture_source.video.end_time": "Время окончания (с):", - "picture_source.video.resolution_limit": "Макс. ширина (px):", - "picture_source.video.resolution_limit.hint": "Уменьшение видео при декодировании для производительности", - "streams.image_asset": "Изображение:", - "streams.image_asset.select": "Выберите изображение…", - "streams.image_asset.search": "Поиск изображений…", - "streams.video_asset": "Видео:", - "streams.video_asset.select": "Выберите видео…", - "streams.video_asset.search": "Поиск видео…", - "targets.title": "Каналы", - "targets.description": "Цели связывают источники цветовых полос с устройствами вывода. Каждая цель ссылается на устройство и источник цветовой полосы.", - "targets.subtab.wled": "LED", - "targets.subtab.led": "LED", - "targets.section.devices": "Устройства", - "targets.section.color_strips": "Источники цветовых полос", - "targets.section.targets": "Цели", - "targets.section.specific_settings": "Специальные настройки", - "targets.section.advanced": "Расширенные", - "targets.add": "Добавить Цель", - "targets.edit": "Редактировать Цель", - "targets.loading": "Загрузка целей...", - "targets.none": "Цели не настроены", - "targets.failed": "Не удалось загрузить цели", - "targets.name": "Имя Цели:", - "targets.name.placeholder": "Моя Цель", - "targets.device": "Устройство:", - "targets.device.hint": "Выберите LED устройство для передачи данных", - "targets.device.none": "-- Выберите устройство --", - "targets.color_strip_source": "Источник цветовой полосы:", - "targets.color_strip_source.hint": "Выберите источник цветовой полосы, который предоставляет цвета LED для этой цели", - "targets.no_css": "Нет источника", - "targets.source": "Источник:", - "targets.source.hint": "Какой источник изображения захватывать и обрабатывать", - "targets.source.none": "-- Источник не назначен --", - "targets.fps": "Целевой FPS:", - "targets.fps.hint": "Целевая частота кадров для захвата и обновления LED (1-90)", - "targets.fps.rec": "Макс. аппаратный ≈ {fps} fps ({leds} LED)", - "targets.border_width": "Ширина границы (px):", - "targets.border_width.hint": "Сколько пикселей от края экрана выбирать для цвета LED (1-100)", - "targets.interpolation": "Режим интерполяции:", - "targets.interpolation.hint": "Как вычислять цвет LED из выбранных пикселей", - "targets.interpolation.average": "Среднее", - "targets.interpolation.median": "Медиана", - "targets.interpolation.dominant": "Доминантный", - "targets.smoothing": "Сглаживание:", - "targets.smoothing.hint": "Временное смешивание между кадрами (0=нет, 1=полное). Уменьшает мерцание.", - "targets.keepalive_interval": "Интервал поддержания связи:", - "targets.keepalive_interval.hint": "Как часто повторно отправлять последний кадр при статичном источнике для удержания устройства в режиме live (0.5-5.0с)", - "targets.created": "Цель успешно создана", - "targets.updated": "Цель успешно обновлена", - "targets.deleted": "Цель успешно удалена", - "targets.delete.confirm": "Вы уверены, что хотите удалить эту цель?", - "targets.error.load": "Не удалось загрузить цели", - "targets.error.required": "Пожалуйста, заполните все обязательные поля", - "targets.error.name_required": "Введите название цели", - "targets.error.delete": "Не удалось удалить цель", - "targets.button.start": "Запустить", - "targets.button.stop": "Остановить", - "targets.status.processing": "Обработка", - "targets.status.idle": "Ожидание", - "targets.status.error": "Ошибка", - "targets.metrics.actual_fps": "Факт. FPS", - "targets.metrics.target_fps": "Целев. FPS", - "targets.metrics.frames": "Кадры", - "targets.metrics.errors": "Ошибки", - "targets.section.pattern_templates": "Шаблоны Паттернов", - "pattern.add": "Добавить Шаблон Паттерна", - "pattern.edit": "Редактировать Шаблон Паттерна", - "pattern.name": "Имя Шаблона:", - "pattern.name.placeholder": "Мой Шаблон Паттерна", - "pattern.description_label": "Описание (необязательно):", - "pattern.description_placeholder": "Опишите этот паттерн...", - "pattern.rectangles": "Прямоугольники", - "pattern.rect.name": "Имя", - "pattern.rect.x": "X", - "pattern.rect.y": "Y", - "pattern.rect.width": "Ш", - "pattern.rect.height": "В", - "pattern.rect.add": "Добавить Прямоугольник", - "pattern.rect.remove": "Удалить", - "pattern.rect.empty": "Прямоугольники не определены. Добавьте хотя бы один.", - "pattern.created": "Шаблон паттерна успешно создан", - "pattern.updated": "Шаблон паттерна успешно обновлён", - "pattern.deleted": "Шаблон паттерна успешно удалён", - "pattern.delete.confirm": "Вы уверены, что хотите удалить этот шаблон паттерна?", - "pattern.delete.referenced": "Невозможно удалить: шаблон используется целью", - "pattern.error.required": "Пожалуйста, заполните все обязательные поля", - "pattern.visual_editor": "Визуальный Редактор", - "pattern.capture_bg": "Захватить Фон", - "pattern.source_for_bg": "Источник для Фона:", - "pattern.source_for_bg.none": "-- Выберите источник --", - "pattern.delete_selected": "Удалить Выбранный", - "pattern.name.hint": "Описательное имя для этой раскладки прямоугольников", - "pattern.description.hint": "Необязательные заметки о назначении этого паттерна", - "pattern.visual_editor.hint": "Нажмите кнопки + чтобы добавить прямоугольники. Тяните края для изменения размера, тяните внутри для перемещения.", - "pattern.rectangles.hint": "Точная настройка позиций и размеров прямоугольников в координатах (0.0 до 1.0)", - "overlay.toggle": "Переключить наложение на экран", - "overlay.button.show": "Показать визуализацию наложения", - "overlay.button.hide": "Скрыть визуализацию наложения", - "overlay.started": "Визуализация наложения запущена", - "overlay.stopped": "Визуализация наложения остановлена", - "overlay.error.start": "Не удалось запустить наложение", - "overlay.error.stop": "Не удалось остановить наложение", - "sidebar.workspaces": "Разделы", - "sidebar.load": "Нагр.", - "sidebar.fps": "FPS", - "transport.status.ready": "Готов", - "transport.status.armed": "Активно · {n}", - "transport.meta.uptime": "Время", - "transport.meta.cpu": "CPU", - "transport.meta.mem": "Память", - "transport.meta.poll": "Опрос", - "transport.meta.poll_hint": "Интервал опроса (клик: 1с → 2с → 5с → 10с)", - "dashboard.title": "Обзор", - "dashboard.section.targets": "Каналы", - "dashboard.section.running": "Запущенные", - "dashboard.section.stopped": "Остановленные", - "dashboard.no_targets": "Нет настроенных целей", - "dashboard.uptime": "Время работы", - "dashboard.fps": "FPS", - "dashboard.errors": "Ошибки", - "dashboard.device": "Устройство", - "dashboard.stop_all": "Остановить все", - "dashboard.failed": "Не удалось загрузить обзор", - "dashboard.section.automations": "Автоматизации", - "dashboard.section.scenes": "Пресеты сцен", - "dashboard.section.playlists": "Плейлисты", - "dashboard.section.sync_clocks": "Синхронные часы", - "dashboard.targets": "Цели", - "dashboard.section.performance": "Производительность системы", - "dashboard.perf.active_patches": "Активные каналы", - "dashboard.perf.patches.empty.idle": "Готов к запуску", - "dashboard.perf.patches.empty.none": "Каналов пока нет", - "dashboard.perf.total_fps": "Общий FPS", - "dashboard.perf.total_capture_fps": "Общий FPS источников", - "dashboard.perf.total_capture_fps_actual": "Общий FPS захвата", - "dashboard.perf.network": "Сеть", - "dashboard.perf.device_latency": "Задержка устройств", - "dashboard.perf.send_timing": "Время отправки", - "dashboard.perf.errors": "Ошибки", - "dashboard.perf.devices": "Устройства", - "dashboard.perf.cpu": "ЦП", - "dashboard.perf.ram": "ОЗУ", - "dashboard.perf.gpu": "ГП", - "dashboard.perf.temp": "Температура", - "dashboard.perf.temp.install_lhm": "В Windows нет встроенного API для температуры CPU. Установите LibreHardwareMonitor и включите «Publish to WMI», чтобы видеть живые показания.", - "dashboard.perf.unavailable": "недоступно", - "dashboard.perf.color": "Цвет графика", - "dashboard.perf.mode.system": "Система", - "dashboard.perf.mode.app": "Приложение", - "dashboard.perf.mode.both": "Оба", - "dashboard.poll_interval": "Интервал обновления", - "dashboard.customize.title": "Настройка панели", - "dashboard.customize.presets": "Пресеты", - "dashboard.customize.preset.studio": "Студия", - "dashboard.customize.preset.operator": "Оператор", - "dashboard.customize.preset.showrunner": "Шоу", - "dashboard.customize.preset.diagnostics": "Диагностика", - "dashboard.customize.preset.tv": "ТВ", - "dashboard.customize.modified": "Изменено", - "dashboard.customize.global": "Общие", - "dashboard.customize.width": "Ширина", - "dashboard.customize.width.full": "Полная", - "dashboard.customize.width.centered": "По центру", - "dashboard.customize.width.narrow": "Узкая", - "dashboard.customize.anim": "Анимации", - "dashboard.customize.anim.full": "Полные", - "dashboard.customize.anim.reduced": "Снижены", - "dashboard.customize.anim.off": "Выкл", - "dashboard.customize.perf_mode": "Режим перф.", - "dashboard.customize.sections": "Секции", - "dashboard.customize.perf_cells": "Системный мониторинг", - "dashboard.customize.fixed_top": "Закреплено сверху", - "dashboard.customize.drag_help": "Перетащите строки или используйте ↑/↓.", - "dashboard.customize.cell_drag_help": "Перетащите строку, чтобы изменить порядок ячеек в полосе производительности.", - "dashboard.customize.window": "Окно выборки", - "dashboard.customize.scale": "Шкала Y", - "dashboard.customize.mode_short": "РЕЖ", - "dashboard.customize.window_short": "ОКН", - "dashboard.customize.scale_short": "ШКЛ", - "dashboard.customize.density.comfortable": "Просторно", - "dashboard.customize.density.compact": "Компактно", - "dashboard.customize.density.dense": "Плотно", - "card_mode.tooltip": "Размер карточек", - "card_mode.comfortable": "Просторно", - "card_mode.compact": "Компактно", - "card_mode.dense": "Плотно", - "card_mode.row": "Список", - "dashboard.customize.collapse_default.on": "Свёрнуто по умолчанию", - "dashboard.customize.collapse_default.off": "Развёрнуто по умолчанию", - "dashboard.customize.show": "Показать", - "dashboard.customize.hide": "Скрыть", - "dashboard.customize.mode.inherit": "Наслед.", - "dashboard.customize.mode.system": "Сис", - "dashboard.customize.mode.app": "Прил", - "dashboard.customize.mode.both": "Оба", - "dashboard.customize.yscale.auto": "Авто", - "dashboard.customize.yscale.fixed": "Фикс.", - "dashboard.customize.yscale.log": "Лог.", - "dashboard.customize.export": "Экспорт", - "dashboard.customize.import": "Импорт", - "dashboard.customize.reset": "Сбросить", - "dashboard.customize.reset_confirm": "Сбросить настройки панели к пресету «Студия»?", - "dashboard.customize.exported": "Настройки экспортированы", - "dashboard.customize.imported": "Настройки импортированы", - "dashboard.customize.import_failed": "Не удалось импортировать настройки", - "automations.title": "Автоматизации", - "automations.empty": "Автоматизации не настроены. Создайте автоматизацию для автоматической активации сцен.", - "automations.add": "Добавить автоматизацию", - "automations.edit": "Редактировать автоматизацию", - "automations.delete.confirm": "Удалить автоматизацию \"{name}\"?", - "automations.section.triggers": "Триггеры", - "automations.section.action": "Действие", - "automations.section.deactivation": "Деактивация", - "automations.name": "Название:", - "automations.name.hint": "Описательное имя для автоматизации", - "automations.name.placeholder": "Моя автоматизация", - "automations.enabled": "Включена:", - "automations.enabled.hint": "Отключённые автоматизации не активируются даже при выполнении условий", - "automations.rule_logic": "Логика условий:", - "automations.rule_logic.hint": "Как объединяются несколько условий: ЛЮБОЕ (ИЛИ) или ВСЕ (И)", - "automations.rule_logic.or": "Любое условие (ИЛИ)", - "automations.rule_logic.and": "Все условия (И)", - "automations.rule_logic.or.desc": "Срабатывает при любом совпадении", - "automations.rule_logic.and.desc": "Срабатывает только при всех", - "automations.rules": "Условия:", - "automations.rules.hint": "Правила, определяющие когда автоматизация активируется", - "automations.rules.add": "Добавить условие", - "automations.rules.empty": "Нет условий — автоматизация всегда активна когда включена", - "automations.rule.startup": "Автозапуск", - "automations.rule.startup.desc": "При запуске сервера", - "automations.rule.startup.hint": "Активируется при запуске сервера и остаётся активной пока включена.", - "automations.rule.application": "Приложение", - "automations.rule.application.desc": "Приложение запущено", - "automations.rule.application.apps": "Приложения:", - "automations.rule.application.apps.hint": "Имена процессов, по одному на строку (например firefox.exe)", - "automations.rule.application.browse": "Обзор", - "automations.rule.application.search": "Фильтр процессов...", - "automations.rule.application.no_processes": "Процессы не найдены", - "automations.rule.application.match_type": "Тип соответствия:", - "automations.rule.application.match_type.hint": "Как определять наличие приложения", - "automations.rule.application.match_type.running": "Запущено", - "automations.rule.application.match_type.running.desc": "Процесс активен", - "automations.rule.application.match_type.topmost": "На переднем плане", - "automations.rule.application.match_type.topmost.desc": "Окно в фокусе", - "automations.rule.application.match_type.topmost_fullscreen": "Передний план + ПЭ", - "automations.rule.application.match_type.topmost_fullscreen.desc": "В фокусе + полный экран", - "automations.rule.application.match_type.fullscreen": "Полный экран", - "automations.rule.application.match_type.fullscreen.desc": "Любое полноэкранное", - "automations.rule.application.apps.hint_android": "Имена пакетов, по одному в строке (напр. com.netflix.mediaclient)", - "automations.rule.application.search_apps": "Поиск приложений...", - "automations.rule.application.no_apps": "Приложения не найдены", - "automations.rule.application.usage_access_required": "Требуется доступ к статистике использования. Откройте LedGrab на телевизоре и нажмите «Разрешить доступ к статистике использования».", - "automations.rule.time_of_day": "Время суток", - "automations.rule.time_of_day.desc": "Диапазон времени", - "automations.rule.time_of_day.start_time": "Время начала:", - "automations.rule.time_of_day.end_time": "Время окончания:", - "automations.rule.time_of_day.overnight_hint": "Для ночных диапазонов (например 22:00–06:00) укажите время начала позже времени окончания.", - "automations.rule.time_of_day.days": "Активные дни", - "automations.rule.time_of_day.days_hint": "Оставьте всё невыбранным для всех дней. Ночные окна относятся ко дню, когда они начинаются.", - "automations.rule.time_of_day.timezone": "Часовой пояс", - "automations.rule.time_of_day.timezone.placeholder": "Локальное время сервера (напр. Europe/Berlin)", - "weekday.short.0": "Пн", - "weekday.short.1": "Вт", - "weekday.short.2": "Ср", - "weekday.short.3": "Чт", - "weekday.short.4": "Пт", - "weekday.short.5": "Сб", - "weekday.short.6": "Вс", - "automations.rule.system_idle": "Бездействие системы", - "automations.rule.system_idle.desc": "Бездействие/активность", - "automations.rule.system_idle.idle_minutes": "Тайм-аут бездействия (минуты):", - "automations.rule.system_idle.mode": "Режим срабатывания:", - "automations.rule.system_idle.when_idle": "При бездействии", - "automations.rule.system_idle.when_idle.desc": "Срабатывает, когда пользователь не активен дольше тайм-аута", - "automations.rule.system_idle.when_active": "При активности", - "automations.rule.system_idle.when_active.desc": "Срабатывает, пока пользователь активно работает с системой", - "automations.rule.display_state": "Состояние дисплея", - "automations.rule.display_state.desc": "Монитор вкл/выкл", - "automations.rule.display_state.state": "Состояние монитора:", - "automations.rule.display_state.on": "Включён", - "automations.rule.display_state.off": "Выключен (спящий режим)", - "automations.rule.mqtt": "MQTT", - "automations.rule.mqtt.desc": "MQTT сообщение", - "automations.rule.mqtt.topic": "Топик:", - "automations.rule.mqtt.payload": "Значение:", - "automations.rule.mqtt.match_mode": "Режим сравнения:", - "automations.rule.mqtt.match_mode.exact": "Точное совпадение", - "automations.rule.mqtt.match_mode.contains": "Содержит", - "automations.rule.mqtt.match_mode.regex": "Регулярное выражение", - "automations.rule.mqtt.hint": "Активировать при получении совпадающего значения по MQTT топику", - "automations.rule.webhook": "Вебхук", - "automations.rule.webhook.desc": "HTTP вызов", - "automations.rule.webhook.hint": "Активировать через HTTP-запрос от внешних сервисов (Home Assistant, IFTTT, curl и т.д.)", - "automations.rule.webhook.url": "URL вебхука:", - "automations.rule.webhook.copy": "Скопировать", - "automations.rule.webhook.copied": "Скопировано!", - "automations.rule.webhook.save_first": "Сначала сохраните автоматизацию для генерации URL вебхука", - "automations.scene": "Сцена:", - "automations.scene.hint": "Пресет сцены для активации при выполнении условий", - "automations.scene.search_placeholder": "Поиск сцен...", - "automations.scene.none_selected": "Нет (без сцены)", - "automations.scene.none_available": "Нет доступных сцен", - "automations.deactivation_mode": "Деактивация:", - "automations.deactivation_mode.hint": "Что происходит, когда условия перестают выполняться", - "automations.deactivation_mode.none": "Ничего", - "automations.deactivation_mode.none.desc": "Оставить текущее состояние", - "automations.deactivation_mode.revert": "Откатить", - "automations.deactivation_mode.revert.desc": "Вернуть предыдущее состояние", - "automations.deactivation_mode.fallback_scene": "Резервная", - "automations.deactivation_mode.fallback_scene.desc": "Активировать резервную сцену", - "automations.deactivation_scene": "Резервная сцена:", - "automations.deactivation_scene.hint": "Сцена для активации при деактивации автоматизации", - "automations.status.active": "Активна", - "automations.status.inactive": "Неактивна", - "automations.status.disabled": "Отключена", - "automations.action.disable": "Отключить", - "automations.last_activated": "Последняя активация", - "automations.logic.and": " И ", - "automations.logic.or": " ИЛИ ", - "automations.logic.all": "ВСЕ", - "automations.logic.any": "ЛЮБОЕ", - "automations.updated": "Автоматизация обновлена", - "automations.created": "Автоматизация создана", - "automations.deleted": "Автоматизация удалена", - "automations.error.name_required": "Введите название", - "automations.error.clone_failed": "Не удалось клонировать автоматизацию", - "automations.error.load_failed": "Не удалось загрузить автоматизацию", - "automations.error.save_failed": "Не удалось сохранить автоматизацию", - "automations.error.delete_failed": "Не удалось удалить автоматизацию", - "automations.error.toggle_failed": "Не удалось переключить автоматизацию", - "scenes.title": "Сцены", - "scenes.add": "Захватить сцену", - "scenes.edit": "Редактировать сцену", - "scenes.name": "Название:", - "scenes.name.hint": "Описательное имя для этого пресета сцены", - "scenes.name.placeholder": "Моя сцена", - "scenes.description": "Описание:", - "scenes.description.hint": "Необязательное описание назначения этой сцены", - "scenes.targets": "Цели:", - "scenes.targets.hint": "Выберите какие цели включить в снимок сцены", - "scenes.targets.add": "Добавить цель", - "scenes.targets.search_placeholder": "Поиск целей...", - "scenes.targets.empty": "Цели не выбраны", - "scenes.capture": "Захват", - "scenes.activate": "Активировать сцену", - "scenes.recapture": "Перезахватить текущее состояние", - "scenes.action.activate": "Активировать", - "scenes.action.recapture": "Перезахват", - "scenes.status.preset": "Пресет", - "scenes.delete": "Удалить сцену", - "scenes.targets_count": "целей", - "scenes.captured": "Сцена захвачена", - "scenes.updated": "Сцена обновлена", - "scenes.activated": "Сцена активирована", - "scenes.activated_partial": "Сцена активирована частично", - "scenes.errors": "ошибок", - "scenes.recaptured": "Сцена перезахвачена", - "scenes.deleted": "Сцена удалена", - "scenes.recapture_confirm": "Перезахватить текущее состояние в \"{name}\"?", - "scenes.delete_confirm": "Удалить сцену \"{name}\"?", - "scenes.error.name_required": "Необходимо указать название", - "scenes.error.save_failed": "Не удалось сохранить сцену", - "scenes.error.activate_failed": "Не удалось активировать сцену", - "scenes.error.recapture_failed": "Не удалось перезахватить сцену", - "scenes.error.delete_failed": "Не удалось удалить сцену", - "scenes.cloned": "Сцена клонирована", - "scenes.error.clone_failed": "Не удалось клонировать сцену", - "playlists.title": "Плейлисты", - "playlists.add": "Новый плейлист", - "playlists.edit": "Изменить плейлист", - "playlists.name": "Название:", - "playlists.name.placeholder": "Мой плейлист", - "playlists.description": "Описание:", - "playlists.description.hint": "Необязательное описание плейлиста", - "playlists.section.playback": "Воспроизведение", - "playlists.loop": "Зацикливание:", - "playlists.loop.hint": "Начинать заново с первой сцены после последней; если выключено — проиграть один раз и остановиться", - "playlists.shuffle": "Перемешивание:", - "playlists.shuffle.hint": "Случайный порядок сцен в начале каждого цикла", - "playlists.scenes": "Сцены:", - "playlists.scenes.hint": "Пресеты сцен, которые перебирает плейлист, каждая удерживается своё время", - "playlists.scenes.add": "Добавить сцену", - "playlists.scenes_count": "сцен", - "playlists.scene_one": "сцена", - "playlists.scene_many": "сцен", - "playlists.items.empty": "Сцен пока нет — добавьте ниже", - "playlists.items.search_placeholder": "Поиск сцен...", - "playlists.item.duration": "Секунды", - "playlists.item.move_up": "Вверх", - "playlists.item.move_down": "Вниз", - "playlists.item.missing": "Отсутствует", - "playlists.chip.loop": "Цикл", - "playlists.chip.shuffle": "Перемешать", - "playlists.action.start": "Запустить", - "playlists.action.stop": "Остановить", - "playlists.start": "Запустить плейлист", - "playlists.stop": "Остановить плейлист", - "playlists.status.playing": "Воспроизводится", - "playlists.status.stopped": "Остановлен", - "playlists.started": "Плейлист запущен", - "playlists.stopped": "Плейлист остановлен", - "playlists.created": "Плейлист создан", - "playlists.updated": "Плейлист обновлён", - "playlists.deleted": "Плейлист удалён", - "playlists.delete_confirm": "Удалить плейлист «{name}»?", - "playlists.error.name_required": "Требуется название", - "playlists.error.save_failed": "Не удалось сохранить плейлист", - "playlists.error.start_failed": "Не удалось запустить плейлист", - "playlists.error.stop_failed": "Не удалось остановить плейлист", - "playlists.error.delete_failed": "Не удалось удалить плейлист", - "playlists.error.no_presets": "Сначала создайте пресет сцены", - "dashboard.type.led": "LED", - "dashboard.type.kc": "Цвета клавиш", - "aria.close": "Закрыть", - "aria.save": "Сохранить", - "aria.cancel": "Отмена", - "aria.previous": "Назад", - "aria.next": "Вперёд", - "aria.hint": "Показать подсказку", - "color_strip.select_type": "Выберите тип цветовой полосы", - "color_strip.add": "Добавить", - "color_strip.edit": "Редактировать", - "color_strip.name": "Название:", - "color_strip.name.placeholder": "Настенная полоса", - "color_strip.picture_source": "Источник изображения:", - "color_strip.picture_source.hint": "Источник захвата экрана для расчёта цветов светодиодов", - "color_strip.fps": "Целевой FPS:", - "color_strip.fps.hint": "Целевая частота кадров для обновления цветов светодиодов (10-90)", - "color_strip.interpolation": "Режим цвета:", - "color_strip.interpolation.hint": "Как вычислять цвет светодиода по пикселям рамки", - "color_strip.interpolation.average": "Среднее", - "color_strip.interpolation.median": "Медиана", - "color_strip.interpolation.dominant": "Доминирующий", - "color_strip.interpolation.average.desc": "Смешивает все пиксели в усреднённый цвет", - "color_strip.interpolation.median.desc": "Берёт средний цвет, игнорируя выбросы", - "color_strip.interpolation.dominant.desc": "Использует самый частый цвет в выборке", - "color_strip.smoothing": "Сглаживание:", - "color_strip.smoothing.hint": "Временное смешивание кадров (0=без смешивания, 1=полное). Уменьшает мерцание.", - "color_strip.frame_interpolation": "Интерполяция кадров:", - "color_strip.frame_interpolation.hint": "Смешивает последовательные захваченные кадры для вывода на полной целевой частоте кадров, даже если скорость захвата ниже. Уменьшает заметные ступеньки при плавных переходах.", - "color_strip.color_corrections": "Цветокоррекция", - "color_strip.brightness": "Яркость:", - "color_strip.brightness.hint": "Множитель яркости (0=выкл, 1=без изменений, 2=двойная). Применяется после извлечения цвета.", - "color_strip.saturation": "Насыщенность:", - "color_strip.saturation.hint": "Насыщенность цвета (0=оттенки серого, 1=без изменений, 2=двойная насыщенность)", - "color_strip.gamma": "Гамма:", - "color_strip.gamma.hint": "Гамма-коррекция (1=без коррекции, <1=ярче средние тона, >1=темнее средние тона)", - "color_strip.test_device": "Тестировать на устройстве:", - "color_strip.test_device.hint": "Выберите устройство для отправки тестовых пикселей при нажатии на рамку", - "color_strip.leds": "Количество светодиодов", - "color_strip.led_count": "Количество LED:", - "color_strip.led_count.hint": "Общее число светодиодов на физической полосе. Для источников экрана: 0 = автоматически из калибровки (светодиоды за ТВ будут чёрными). Для статического цвета: укажите точное количество светодиодов устройства.", - "color_strip.created": "Источник цветовой полосы создан", - "color_strip.updated": "Источник цветовой полосы обновлён", - "color_strip.deleted": "Источник цветовой полосы удалён", - "color_strip.delete.confirm": "Удалить этот источник цветовой полосы?", - "color_strip.delete.referenced": "Невозможно удалить: источник используется в цели", - "color_strip.error.name_required": "Введите название", - "color_strip.type": "Тип:", - "color_strip.type.hint": "Источник изображения получает цвета светодиодов из захвата экрана. Один цвет заполняет все светодиоды одним постоянным цветом. Градиент распределяет цветовой градиент по всем светодиодам. Смена цвета плавно циклически переключается между заданными цветами. Композит накладывает несколько источников как смешанные слои. Аудиореактив управляет LED от аудиосигнала в реальном времени. API-ввод принимает массивы цветов LED от внешних клиентов через REST или WebSocket.", - "color_strip.type.picture": "Источник изображения", - "color_strip.type.picture.desc": "Цвета из захвата экрана", - "color_strip.type.picture_advanced": "Мультимонитор", - "color_strip.type.picture_advanced.desc": "Калибровка линиями по нескольким мониторам", - "color_strip.type.single_color": "Один цвет", - "color_strip.type.single_color.desc": "Заливка одним цветом", - "color_strip.type.gradient": "Градиент", - "color_strip.type.gradient.desc": "Плавный переход цветов по ленте", - "color_strip.single_color": "Цвет:", - "color_strip.single_color.hint": "Один сплошной цвет, который будет отправлен на все светодиоды полосы.", - "color_strip.gradient.preview": "Градиент:", - "color_strip.gradient.preview.hint": "Предпросмотр градиента. Нажмите на дорожку маркеров чтобы добавить остановку. Перетащите маркеры для изменения позиции.", - "color_strip.gradient.stops": "Цветовые остановки:", - "color_strip.gradient.stops.hint": "Каждая остановка задаёт цвет в относительной позиции (0.0 = начало, 1.0 = конец). Кнопка ↔ добавляет цвет справа для создания резкого перехода.", - "color_strip.gradient.stops_count": "остановок", - "color_strip.gradient.add_stop": "+ Добавить", - "color_strip.gradient.position": "Позиция (0.0–1.0)", - "color_strip.gradient.bidir.hint": "Добавить второй цвет справа от этой остановки для создания резкого перехода в градиенте.", - "color_strip.gradient.min_stops": "Градиент должен содержать не менее 2 остановок", - "color_strip.gradient.preset": "Пресет:", - "color_strip.gradient.preset.hint": "Загрузить готовую палитру градиента. Выбор пресета заменяет текущие остановки.", - "color_strip.gradient.preset.custom": "— Свой —", - "color_strip.gradient.preset.rainbow": "Радуга", - "color_strip.gradient.preset.sunset": "Закат", - "color_strip.gradient.preset.ocean": "Океан", - "color_strip.gradient.preset.forest": "Лес", - "color_strip.gradient.preset.fire": "Огонь", - "color_strip.gradient.preset.lava": "Лава", - "color_strip.gradient.preset.aurora": "Аврора", - "color_strip.gradient.preset.ice": "Лёд", - "color_strip.gradient.preset.warm": "Тёплый", - "color_strip.gradient.preset.cool": "Холодный", - "color_strip.gradient.preset.neon": "Неон", - "color_strip.gradient.preset.pastel": "Пастельный", - "color_strip.gradient.preset.save_button": "Сохранить как пресет…", - "color_strip.gradient.preset.save_prompt": "Введите название пресета:", - "color_strip.gradient.preset.saved": "Пресет сохранён", - "color_strip.gradient.preset.deleted": "Пресет удалён", - "color_strip.gradient.preset.apply": "Применить", - "color_strip.animation": "Анимация", - "color_strip.animation.type": "Эффект:", - "color_strip.animation.type.hint": "Эффект анимации.", - "color_strip.animation.type.none": "Нет (без эффекта анимации)", - "color_strip.animation.type.none.desc": "Статичные цвета без анимации", - "color_strip.animation.type.breathing": "Дыхание", - "color_strip.animation.type.breathing.desc": "Плавное угасание и нарастание яркости", - "color_strip.animation.type.gradient_shift": "Сдвиг градиента", - "color_strip.animation.type.gradient_shift.desc": "Сдвигает градиент вдоль ленты", - "color_strip.animation.type.wave": "Волна", - "color_strip.animation.type.wave.desc": "Синусоидальная волна яркости вдоль ленты", - "color_strip.animation.type.strobe": "Стробоскоп", - "color_strip.animation.type.strobe.desc": "Быстрое мигание вкл/выкл", - "color_strip.animation.type.sparkle": "Искры", - "color_strip.animation.type.sparkle.desc": "Случайные светодиоды кратковременно вспыхивают", - "color_strip.animation.type.pulse": "Пульс", - "color_strip.animation.type.pulse.desc": "Резкая вспышка яркости с быстрым затуханием", - "color_strip.animation.type.candle": "Свеча", - "color_strip.animation.type.candle.desc": "Тёплое мерцание, как у свечи", - "color_strip.animation.type.rainbow_fade": "Радужный перелив", - "color_strip.animation.type.rainbow_fade.desc": "Циклический переход по всему спектру оттенков", - "color_strip.animation.speed": "Скорость:", - "color_strip.animation.speed.hint": "Множитель скорости анимации. 1.0 ≈ один цикл в секунду для дыхания; большие значения ускоряют анимацию.", - "color_strip.type.effect": "Эффект", - "color_strip.type.effect.desc": "Процедурные эффекты: огонь, плазма, аврора", - "color_strip.type.effect.hint": "Процедурные LED-эффекты (огонь, метеор, плазма, шум, аврора), генерируемые в реальном времени.", - "color_strip.type.composite": "Композит", - "color_strip.type.composite.desc": "Наложение и смешивание источников", - "color_strip.type.composite.hint": "Наложение нескольких источников цветовой ленты как слоёв с режимами смешивания и прозрачностью.", - "color_strip.type.mapped": "Маппинг", - "color_strip.type.mapped.desc": "Назначение источников на зоны LED", - "color_strip.type.mapped.hint": "Назначает разные источники цветовой полосы на разные диапазоны LED (зоны). В отличие от композита, маппинг размещает источники рядом друг с другом.", - "color_strip.type.audio": "Аудиореактив", - "color_strip.type.audio.desc": "LED от аудиосигнала", - "color_strip.type.audio.hint": "Цвета LED управляются аудиосигналом в реальном времени — системный звук или микрофон.", - "color_strip.type.api_input": "API-ввод", - "color_strip.type.api_input.desc": "Приём цветов от внешних приложений", - "color_strip.type.api_input.hint": "Принимает массивы цветов LED от внешних клиентов через REST POST или WebSocket. Используйте для интеграции с собственным ПО, домашней автоматизацией или любой системой, способной отправлять HTTP-запросы.", - "color_strip.api_input.fallback_color": "Цвет по умолчанию:", - "color_strip.api_input.fallback_color.hint": "Цвет для отображения, когда данные не получены в течение периода ожидания. LED покажут этот цвет при запуске и после потери соединения.", - "color_strip.api_input.timeout": "Тайм-аут (секунды):", - "color_strip.api_input.timeout.hint": "Время ожидания новых данных о цветах перед возвратом к цвету по умолчанию. Установите 0, чтобы не использовать тайм-аут.", - "color_strip.api_input.endpoints": "Эндпоинты для отправки:", - "color_strip.api_input.endpoints.hint": "Используйте эти URL для отправки данных о цветах LED из вашего внешнего приложения. REST принимает JSON, WebSocket принимает как JSON, так и бинарные кадры.", - "color_strip.api_input.save_first": "Сначала сохраните источник, чтобы увидеть URL эндпоинтов.", - "color_strip.api_input.interpolation": "Интерполяция LED:", - "color_strip.api_input.interpolation.hint": "Как масштабировать входящие данные LED, когда их количество отличается от количества LED на устройстве. Линейная — плавное смешивание, Ближайший — чёткие границы, Нет — обрезка или дополнение нулями.", - "color_strip.api_input.interpolation.linear": "Линейная", - "color_strip.api_input.interpolation.linear.desc": "Плавное смешивание между LED", - "color_strip.api_input.interpolation.nearest": "Ближайший", - "color_strip.api_input.interpolation.nearest.desc": "Чёткие границы, без смешивания", - "color_strip.api_input.interpolation.none": "Нет", - "color_strip.api_input.interpolation.none.desc": "Обрезка или дополнение нулями", - "color_strip.type.notification": "Уведомления", - "color_strip.type.notification.desc": "Разовый эффект по вебхуку", - "color_strip.type.notification.hint": "Вспышка, пульс или волна при срабатывании через вебхук. Предназначен для использования как слой в композитном источнике.", - "color_strip.notification.os_listener": "Слушать системные уведомления:", - "color_strip.notification.os_listener.hint": "Когда включено, источник автоматически срабатывает при появлении уведомления рабочего стола (Windows toast / Linux D-Bus). Требует разрешения на доступ к уведомлениям.", - "color_strip.notification.effect": "Эффект:", - "color_strip.notification.effect.hint": "Визуальный эффект при уведомлении. Вспышка — линейное затухание, Пульс — плавная волна, Волна — заполнение и затухание.", - "color_strip.notification.effect.flash": "Вспышка", - "color_strip.notification.effect.flash.desc": "Мгновенное включение, линейное затухание", - "color_strip.notification.effect.pulse": "Пульс", - "color_strip.notification.effect.pulse.desc": "Плавное свечение колоколом", - "color_strip.notification.effect.sweep": "Волна", - "color_strip.notification.effect.sweep.desc": "Заполняет слева направо, затем гаснет", - "color_strip.notification.duration": "Длительность (мс):", - "color_strip.notification.duration.hint": "Как долго длится эффект уведомления в миллисекундах.", - "color_strip.notification.default_color": "Цвет по умолчанию:", - "color_strip.notification.default_color.hint": "Цвет, когда для приложения нет специфического назначения цвета.", - "color_strip.notification.filter_mode": "Фильтр приложений:", - "color_strip.notification.filter_mode.hint": "Фильтр уведомлений по имени приложения. Выкл = все, Белый список = только указанные, Чёрный список = все кроме указанных.", - "color_strip.notification.filter_mode.off": "Выкл", - "color_strip.notification.filter_mode.whitelist": "Белый список", - "color_strip.notification.filter_mode.blacklist": "Чёрный список", - "color_strip.notification.filter_mode.off.desc": "Принимать все уведомления", - "color_strip.notification.filter_mode.whitelist.desc": "Только указанные приложения", - "color_strip.notification.filter_mode.blacklist.desc": "Все кроме указанных приложений", - "color_strip.notification.filter_list": "Список приложений:", - "color_strip.notification.filter_list.hint": "Одно имя приложения на строку. Используйте «Обзор» для выбора из запущенных процессов.", - "color_strip.notification.filter_list.placeholder": "Discord\nSlack\nTelegram", - "color_strip.notification.app_colors": "Цвета приложений", - "color_strip.notification.app_colors.label": "Назначения цветов:", - "color_strip.notification.app_colors.hint": "Индивидуальные цвета для приложений. Каждая строка связывает имя приложения с цветом уведомления.", - "color_strip.notification.app_colors.add": "+ Добавить", - "color_strip.notification.app_overrides": "Настройки приложений", - "color_strip.notification.app_overrides.label": "Переопределения:", - "color_strip.notification.app_overrides.hint": "Индивидуальные настройки цвета и звука для каждого приложения.", - "color_strip.notification.app_overrides.add": "+ Добавить", - "color_strip.notification.app_overrides.app_placeholder": "Имя приложения", - "color_strip.notification.sound": "Звук", - "color_strip.notification.sound.asset": "Звуковой ассет:", - "color_strip.notification.sound.asset.hint": "Выберите звуковой ассет для воспроизведения при уведомлении. Оставьте пустым для тишины.", - "color_strip.notification.sound.none": "Нет (без звука)", - "color_strip.notification.sound.search": "Поиск звуков…", - "color_strip.notification.sound.volume": "Громкость:", - "color_strip.notification.sound.volume.hint": "Общая громкость звуков уведомлений (0–100%).", - "color_strip.notification.sound.app_sounds": "Звуки приложений:", - "color_strip.notification.sound.app_sounds.hint": "Переопределение звука и громкости для конкретных приложений. Пустой звук = отключить для этого приложения.", - "color_strip.notification.sound.app_sounds.add": "+ Добавить", - "color_strip.notification.sound.app_name_placeholder": "Имя приложения", - "color_strip.notification.endpoint": "Вебхук:", - "color_strip.notification.endpoint.hint": "URL для запуска уведомлений из внешних систем. POST с JSON телом: {\"app\": \"AppName\", \"color\": \"#FF0000\"}.", - "color_strip.notification.save_first": "Сначала сохраните источник, чтобы увидеть URL вебхука.", - "color_strip.notification.app_count": "прилож.", - "color_strip.notification.test": "Тестовое уведомление", - "color_strip.notification.test.ok": "Уведомление отправлено", - "color_strip.notification.test.no_streams": "Нет запущенных потоков для этого источника", - "color_strip.notification.test.error": "Не удалось отправить уведомление", - "color_strip.notification.history.title": "История уведомлений", - "color_strip.notification.history.hint": "Последние ОС-уведомления, захваченные слушателем (новейшие сверху). До 50 записей.", - "color_strip.notification.history.empty": "Уведомления ещё не захвачены", - "color_strip.notification.history.unavailable": "Слушатель уведомлений ОС недоступен на этой платформе", - "color_strip.notification.history.error": "Не удалось загрузить историю уведомлений", - "color_strip.notification.history.refresh": "Обновить", - "color_strip.notification.history.unknown_app": "Неизвестное приложение", - "color_strip.notification.history.fired": "Потоков запущено", - "color_strip.notification.history.filtered": "Потоков отфильтровано", - "color_strip.test.title": "Предпросмотр", - "color_strip.test.connecting": "Подключение...", - "color_strip.test.error": "Не удалось подключиться к потоку предпросмотра", - "color_strip.test.led_count": "Кол-во LED:", - "color_strip.test.fps": "FPS:", - "color_strip.test.receive_fps": "Частота приёма", - "color_strip.test.apply": "Применить", - "color_strip.test.composite": "Композит", - "color_strip.preview.title": "Предпросмотр", - "color_strip.preview.not_connected": "Не подключено", - "color_strip.preview.connecting": "Подключение...", - "color_strip.preview.connected": "Подключено", - "color_strip.preview.unsupported": "Предпросмотр недоступен для этого типа источника", - "color_strip.preview.save_first": "Сначала сохраните источник — для предпросмотра нужна калибровка", - "color_strip.type.daylight": "Дневной цикл", - "color_strip.type.daylight.desc": "Имитация естественного дневного света за 24 часа", - "color_strip.type.daylight.hint": "Имитирует цветовую температуру солнца в течение суток — от тёплого рассвета до прохладного дневного света, заката и ночи.", - "color_strip.daylight.speed": "Скорость:", - "color_strip.daylight.speed.hint": "Множитель скорости цикла. 1.0 = полный цикл день/ночь за ~4 минуты.", - "color_strip.daylight.use_real_time": "Реальное время:", - "color_strip.daylight.use_real_time.hint": "Если включено, цвет LED соответствует реальному времени суток. Настройка скорости игнорируется.", - "color_strip.daylight.real_time": "Реальное время", - "color_strip.daylight.latitude": "Широта:", - "color_strip.daylight.latitude.hint": "Географическая широта (-90 до 90). Влияет на время восхода/заката в режиме реального времени.", - "color_strip.type.candlelight": "Свечи", - "color_strip.type.candlelight.desc": "Реалистичная имитация мерцания свечей", - "color_strip.type.candlelight.hint": "Реалистичное мерцание свечей с тёплыми тонами и органическими паттернами.", - "color_strip.type.weather": "Погода", - "color_strip.type.weather.desc": "Погодно-реактивные амбиентные цвета", - "color_strip.type.weather.hint": "Отображает текущие погодные условия в амбиентные цвета LED. Требуется сущность Источник погоды.", - "color_strip.weather.source": "Источник погоды:", - "color_strip.weather.source.hint": "Источник метеоданных. Сначала создайте его во вкладке Погода.", - "color_strip.weather.speed": "Скорость анимации:", - "color_strip.weather.speed.hint": "Скорость дрейфа цветов. Выше = быстрее.", - "color_strip.weather.temperature_influence": "Влияние температуры:", - "color_strip.weather.temperature_influence.hint": "Насколько температура смещает палитру в тёплую/холодную сторону. 0 = чистые цвета условий, 1 = сильное смещение.", - "color_strip.weather.error.no_source": "Выберите источник погоды", - "color_strip.candlelight.color": "Базовый цвет:", - "color_strip.candlelight.color.hint": "Тёплый базовый цвет пламени свечи. По умолчанию — натуральный тёплый янтарь.", - "color_strip.candlelight.intensity": "Интенсивность мерцания:", - "color_strip.candlelight.intensity.hint": "Сила мерцания свечей. Низкие значения — мягкое свечение, высокие — свеча на ветру.", - "color_strip.candlelight.num_candles_label": "Количество свечей:", - "color_strip.candlelight.num_candles": "свечей", - "color_strip.candlelight.num_candles.hint": "Сколько независимых источников свечей вдоль ленты. Каждый мерцает по-своему.", - "color_strip.candlelight.speed": "Скорость мерцания:", - "color_strip.candlelight.speed.hint": "Скорость анимации мерцания. Большие значения — более быстрое, беспокойное пламя.", - "color_strip.type.processed": "Обработанный", - "color_strip.type.processed.desc": "Применить шаблон обработки к другому источнику", - "color_strip.type.processed.hint": "Оборачивает существующий источник цветовой полосы и пропускает его вывод через цепочку фильтров.", - "color_strip.processed.input": "Источник:", - "color_strip.processed.input.hint": "Источник цветовой полосы, вывод которого будет обработан", - "color_strip.processed.template": "Шаблон обработки:", - "color_strip.processed.template.hint": "Цепочка фильтров для применения к выводу входного источника", - "color_strip.processed.error.no_input": "Выберите входной источник", - "color_strip.composite.layers": "Слои:", - "color_strip.composite.layers.hint": "Наложение нескольких источников. Первый слой — нижний, последний — верхний. Каждый слой может иметь свой режим смешивания и прозрачность.", - "color_strip.composite.add_layer": "Добавить слой", - "color_strip.composite.source": "Источник", - "color_strip.composite.blend_mode": "Смешивание", - "color_strip.composite.blend_mode.normal": "Обычное", - "color_strip.composite.blend_mode.normal.desc": "Стандартное альфа-смешивание", - "color_strip.composite.blend_mode.add": "Сложение", - "color_strip.composite.blend_mode.add.desc": "Осветляет, складывая цвета", - "color_strip.composite.blend_mode.multiply": "Умножение", - "color_strip.composite.blend_mode.multiply.desc": "Затемняет, умножая цвета", - "color_strip.composite.blend_mode.screen": "Экран", - "color_strip.composite.blend_mode.screen.desc": "Осветляет, обратное умножение", - "color_strip.composite.blend_mode.override": "Замена", - "color_strip.composite.blend_mode.overlay": "Наложение", - "color_strip.composite.blend_mode.overlay.desc": "Умножение тёмных, осветление светлых", - "color_strip.composite.blend_mode.soft_light": "Мягкий свет", - "color_strip.composite.blend_mode.soft_light.desc": "Мягкая коррекция контраста", - "color_strip.composite.blend_mode.hard_light": "Жёсткий свет", - "color_strip.composite.blend_mode.hard_light.desc": "Сильный контраст, яркие цвета", - "color_strip.composite.blend_mode.difference": "Разница", - "color_strip.composite.blend_mode.difference.desc": "Абсолютная разница цветов", - "color_strip.composite.blend_mode.exclusion": "Исключение", - "color_strip.composite.blend_mode.exclusion.desc": "Как разница, но мягче", - "color_strip.composite.blend_mode.override.desc": "Чёрный = прозрачный, яркий = непрозрачный", - "color_strip.composite.opacity": "Непрозрачность", - "color_strip.composite.brightness": "Яркость", - "color_strip.composite.brightness.none": "Нет (полная яркость)", - "color_strip.composite.processing": "Обработка", - "color_strip.composite.enabled": "Включён", - "color_strip.composite.error.min_layers": "Необходим хотя бы 1 слой", - "color_strip.composite.error.no_source": "Для каждого слоя должен быть выбран источник", - "color_strip.composite.layers_count": "слоёв", - "color_strip.composite.range": "Диапазон LED", - "color_strip.composite.range_start": "Начало", - "color_strip.composite.range_end": "Конец", - "color_strip.composite.reverse": "Реверс", - "color_strip.mapped.zones": "Зоны:", - "color_strip.mapped.zones.hint": "Каждая зона привязывает источник цветовой полосы к определённому диапазону LED. Зоны размещаются рядом — промежутки остаются чёрными.", - "color_strip.mapped.add_zone": "+ Добавить зону", - "color_strip.mapped.zone_source": "Источник", - "color_strip.mapped.zone_start": "Начало LED", - "color_strip.mapped.zone_end": "Конец LED", - "color_strip.mapped.zone_reverse": "Реверс", - "color_strip.mapped.zones_count": "зон", - "color_strip.mapped.select_source": "Поиск источников...", - "color_strip.mapped.error.no_source": "Для каждой зоны должен быть выбран источник", - "color_strip.audio.visualization": "Визуализация:", - "color_strip.audio.visualization.hint": "Способ отображения аудиоданных на LED.", - "color_strip.audio.viz.spectrum": "Анализатор спектра", - "color_strip.audio.viz.spectrum.desc": "Частотные полосы по ленте", - "color_strip.audio.viz.beat_pulse": "Пульс бита", - "color_strip.audio.viz.beat_pulse.desc": "Все LED пульсируют в такт", - "color_strip.audio.viz.vu_meter": "VU-метр", - "color_strip.audio.viz.vu_meter.desc": "Уровень громкости заполняет ленту", - "color_strip.audio.viz.pulse_on_beat": "Пульс на бит", - "color_strip.audio.viz.pulse_on_beat.desc": "LED пульсируют при каждом ударе", - "color_strip.audio.viz.energy_gradient": "Энергетический градиент", - "color_strip.audio.viz.energy_gradient.desc": "Интенсивность градиента следует за энергией звука", - "color_strip.audio.viz.spectrum_bands": "Полосы спектра", - "color_strip.audio.viz.spectrum_bands.desc": "Группы частот по ленте", - "color_strip.audio.viz.strobe_on_drop": "Стробоскоп на дропе", - "color_strip.audio.viz.strobe_on_drop.desc": "Вспышка стробоскопа на басовых дропах", - "color_strip.audio.beat_decay": "Затухание бита:", - "color_strip.audio.beat_decay.hint": "Скорость затухания пульса. Меньшие значения = более долгое затухание, большие = более резкая реакция.", - "color_strip.audio.source": "Аудиоисточник:", - "color_strip.audio.source.hint": "Аудиоисточник для визуализации. Может быть многоканальным (устройство) или моно (один канал). Создавайте и управляйте аудиоисточниками на вкладке Источники.", - "color_strip.audio.sensitivity": "Чувствительность:", - "color_strip.audio.sensitivity.hint": "Множитель усиления аудиосигнала. Более высокие значения делают LED чувствительнее к тихим звукам.", - "color_strip.audio.smoothing": "Сглаживание:", - "color_strip.audio.smoothing.hint": "Временное сглаживание между кадрами. Более высокие значения дают плавную, но медленнее реагирующую визуализацию.", - "color_strip.audio.palette": "Палитра:", - "color_strip.audio.palette.hint": "Цветовая палитра для полос спектра или пульсации бита.", - "color_strip.audio.color": "Базовый цвет:", - "color_strip.audio.color.hint": "Цвет низкого уровня для полосы VU-метра.", - "color_strip.audio.color_peak": "Пиковый цвет:", - "color_strip.audio.color_peak.hint": "Цвет высокого уровня в верхней части полосы VU-метра.", - "color_strip.audio.mirror": "Зеркало:", - "color_strip.audio.mirror.hint": "Зеркалирование спектра от центра к краям: басы в середине, высокие частоты по краям.", - "color_strip.effect.type": "Тип эффекта:", - "color_strip.effect.type.hint": "Выберите процедурный алгоритм.", - "color_strip.effect.fire": "Огонь", - "color_strip.effect.fire.desc": "Клеточный автомат, имитирующий поднимающееся пламя с диффузией тепла", - "color_strip.effect.meteor": "Метеор", - "color_strip.effect.meteor.desc": "Яркая точка движется по ленте с экспоненциально затухающим хвостом", - "color_strip.effect.plasma": "Плазма", - "color_strip.effect.plasma.desc": "Наложение синусоидальных волн с палитрой — классический демо-эффект", - "color_strip.effect.noise": "Шум", - "color_strip.effect.noise.desc": "Прокручиваемый фрактальный шум, отображённый на палитру", - "color_strip.effect.aurora": "Аврора", - "color_strip.effect.aurora.desc": "Наложенные шумовые полосы, дрейфующие и смешивающиеся — в стиле северного сияния", - "color_strip.effect.speed": "Скорость:", - "color_strip.effect.speed.hint": "Множитель скорости анимации эффекта (0.1 = очень медленно, 10.0 = очень быстро).", - "color_strip.effect.palette": "Палитра:", - "color_strip.effect.palette.hint": "Цветовая палитра для отображения значений эффекта в RGB-цвета.", - "color_strip.effect.color": "Цвет метеора:", - "color_strip.effect.color.hint": "Цвет головной точки метеора.", - "color_strip.effect.intensity": "Интенсивность:", - "color_strip.effect.intensity.hint": "Интенсивность эффекта — частота искр (огонь), затухание хвоста (метеор) или диапазон яркости (аврора).", - "color_strip.effect.scale": "Масштаб:", - "color_strip.effect.scale.hint": "Пространственный масштаб — частота волн (плазма), уровень масштабирования (шум) или ширина полос (аврора).", - "color_strip.effect.mirror": "Отражение:", - "color_strip.effect.mirror.hint": "Режим отскока — метеор меняет направление у краёв ленты вместо переноса.", - "color_strip.palette.fire": "Огонь", - "color_strip.palette.ocean": "Океан", - "color_strip.palette.lava": "Лава", - "color_strip.palette.forest": "Лес", - "color_strip.palette.rainbow": "Радуга", - "color_strip.palette.aurora": "Аврора", - "color_strip.palette.sunset": "Закат", - "color_strip.palette.ice": "Лёд", - "audio_source.title": "Аудиоисточники", - "audio_source.add": "Добавить аудиоисточник", - "audio_source.add.capture": "Добавить источник захвата", - "audio_source.add.processed": "Добавить обработанный источник", - "audio_source.edit": "Редактировать аудиоисточник", - "audio_source.edit.capture": "Редактировать источник захвата", - "audio_source.edit.processed": "Редактировать обработанный источник", - "audio_source.name": "Название:", - "audio_source.name.placeholder": "Системный звук", - "audio_source.name.hint": "Описательное имя для этого аудиоисточника", - "audio_source.type": "Тип:", - "audio_source.type.hint": "Захват оборачивает физическое аудиоустройство. Обработанный применяет фильтры обработки к другому источнику.", - "audio_source.type.capture": "Захват", - "audio_source.type.processed": "Обработанный", - "audio_source.device": "Аудиоустройство:", - "audio_source.device.hint": "Источник аудиосигнала. Устройства обратной петли захватывают системный звук; устройства ввода — микрофон или линейный вход.", - "audio_source.refresh_devices": "Обновить устройства", - "audio_source.parent": "Входной аудиоисточник:", - "audio_source.parent.hint": "Аудиоисточник для применения фильтров обработки", - "audio_source.processing_template": "Шаблон обработки:", - "audio_source.processing_template.hint": "Шаблон обработки аудио с фильтрами для применения к входному источнику", - "audio_source.description": "Описание (необязательно):", - "audio_source.description.placeholder": "Опишите этот аудиоисточник...", - "audio_source.description.hint": "Необязательные заметки об этом аудиоисточнике", - "audio_source.created": "Аудиоисточник создан", - "audio_source.updated": "Аудиоисточник обновлён", - "audio_source.deleted": "Аудиоисточник удалён", - "audio_source.delete.confirm": "Удалить этот аудиоисточник?", - "audio_source.error.name_required": "Введите название", - "audio_source.audio_template": "Аудиошаблон:", - "audio_source.audio_template.hint": "Шаблон аудиозахвата определяет, какой движок и настройки использовать для этого устройства", - "audio_source.test": "Тест", - "audio_source.test.title": "Тест аудиоисточника", - "audio_source.test.rms": "RMS", - "audio_source.test.peak": "Пик", - "audio_source.test.beat": "Бит", - "audio_source.test.connecting": "Подключение...", - "audio_source.test.error": "Ошибка теста аудио", - "audio_template.test": "Тест", - "audio_template.test.title": "Тест аудиошаблона", - "audio_template.test.device": "Аудиоустройство:", - "audio_template.test.device.hint": "Выберите устройство для захвата звука во время теста", - "audio_template.test.run": "Запуск", - "audio_template.title": "Аудиошаблоны", - "audio_template.add": "Добавить аудиошаблон", - "audio_template.edit": "Редактировать аудиошаблон", - "audio_template.name": "Название шаблона:", - "audio_template.name.placeholder": "Мой аудиошаблон", - "audio_template.description.label": "Описание (необязательно):", - "audio_template.description.placeholder": "Опишите этот шаблон...", - "audio_template.engine": "Аудиодвижок:", - "audio_template.engine.hint": "Выберите движок аудиозахвата. WASAPI — только Windows с поддержкой loopback. Sounddevice — кроссплатформенный.", - "audio_template.engine.unavailable": "Недоступен", - "audio_template.engine.unavailable.hint": "Этот движок недоступен в вашей системе", - "audio_template.config": "Конфигурация", - "audio_template.config.show": "Показать конфигурацию", - "audio_template.created": "Аудиошаблон создан", - "audio_template.updated": "Аудиошаблон обновлён", - "audio_template.deleted": "Аудиошаблон удалён", - "audio_template.delete.confirm": "Удалить этот аудиошаблон?", - "audio_template.error.load": "Не удалось загрузить аудиошаблоны", - "audio_template.error.engines": "Не удалось загрузить аудиодвижки", - "audio_template.error.required": "Пожалуйста, заполните все обязательные поля", - "audio_template.error.delete": "Не удалось удалить аудиошаблон", - "audio_template.error.save_failed": "Не удалось сохранить аудиошаблон", - "audio_template.error.load_failed": "Не удалось загрузить аудиошаблон", - "gradient.error.save_failed": "Не удалось сохранить градиент", - "gradient.error.delete_failed": "Не удалось удалить градиент", - "streams.group.value": "Источники значений", - "streams.group.sync": "Часы синхронизации", - "tree.group.picture": "Источники изображений", - "tree.group.capture": "Захват экрана", - "tree.group.static": "Статичные", - "tree.group.processing": "Обработанные", - "tree.group.strip": "Цветовые полосы", - "tree.group.audio": "Аудио", - "tree.group.audio_capture": "Захват", - "tree.group.audio_processed": "Обработка", - "tree.group.utility": "Утилиты", - "tree.leaf.sources": "Источники", - "tree.leaf.engine_templates": "Шаблоны движка", - "tree.leaf.images": "Изображения", - "tree.leaf.video": "Видео", - "tree.leaf.filter_templates": "Шаблоны фильтров", - "tree.leaf.processing_templates": "Шаблоны обработки", - "tree.leaf.templates": "Шаблоны", - "value_source.group.title": "Источники значений", - "value_source.select_type": "Выберите тип источника значений", - "value_source.add": "Добавить источник значений", - "value_source.edit": "Редактировать источник значений", - "value_source.name": "Название:", - "value_source.name.placeholder": "Пульс яркости", - "value_source.name.hint": "Описательное имя для этого источника значений", - "value_source.type": "Тип:", - "value_source.type.hint": "Статический выдаёт постоянное значение. Анимированный циклически меняет форму волны. Аудио реагирует на звук. Адаптивные типы автоматически подстраивают яркость по времени суток или содержимому сцены.", - "value_source.type.static": "Статический", - "value_source.type.static.desc": "Постоянное выходное значение", - "value_source.type.animated": "Анимированный", - "value_source.type.animated.desc": "Циклическая смена по форме волны", - "value_source.type.audio": "Аудио", - "value_source.type.audio.desc": "Реагирует на звуковой сигнал", - "value_source.type.adaptive_time": "Адаптивный (Время)", - "value_source.type.adaptive_time.desc": "Подстройка по времени суток", - "value_source.type.adaptive_scene": "Адаптивный (Сцена)", - "value_source.type.adaptive_scene.desc": "Подстройка по содержимому сцены", - "value_source.type.daylight": "Дневной цикл", - "value_source.type.daylight.desc": "Значение следует циклу дня/ночи", - "value_source.daylight.speed": "Скорость:", - "value_source.daylight.speed.hint": "Множитель скорости цикла. 1.0 = полный цикл день/ночь за ~4 минуты.", - "value_source.daylight.use_real_time": "Реальное время:", - "value_source.daylight.use_real_time.hint": "Значение следует за реальным временем суток. Скорость игнорируется.", - "value_source.normalize": "Нормализовать в 0–1:", - "value_source.normalize.hint": "Вкл.: масштабировать сырое значение в 0–1 по Min/Max. Выкл.: значение ограничивается диапазоном 0–1 как есть (для источников, уже выдающих долю 0–1). Сырое значение остаётся доступным в шаблонах (raw[name]) и автоматизациях.", - "value_source.daylight.enable_real_time": "Следовать за часами", - "value_source.daylight.latitude": "Широта:", - "value_source.daylight.latitude.hint": "Географическая широта (-90 до 90). Делает переходы рассвета и заката круче или плавнее.", - "value_source.daylight.longitude": "Долгота:", - "value_source.daylight.longitude.hint": "Географическая долгота (-180 до 180). Сдвигает солнечный полдень для точного совпадения восхода и заката с настенными часами.", - "value_source.daylight.real_time": "Реальное время", - "value_source.daylight.speed_label": "Скорость", - "value_source.value": "Значение:", - "value_source.value.hint": "Постоянное выходное значение (0.0 = выкл, 1.0 = полная яркость)", - "value_source.waveform": "Форма волны:", - "value_source.waveform.hint": "Форма цикла анимации яркости", - "value_source.waveform.sine": "Синус", - "value_source.waveform.triangle": "Треугольник", - "value_source.waveform.square": "Прямоугольник", - "value_source.waveform.sawtooth": "Пила", - "value_source.speed": "Скорость (цикл/мин):", - "value_source.speed.hint": "Циклов в минуту — как быстро повторяется волна (1 = очень медленно, 120 = очень быстро)", - "value_source.min_value": "Мин. значение:", - "value_source.min_value.hint": "Минимальный выход цикла волны", - "value_source.max_value": "Макс. значение:", - "value_source.max_value.hint": "Максимальный выход цикла волны", - "value_source.audio_source": "Аудиоисточник:", - "value_source.audio_source.hint": "Аудиоисточник для считывания уровня звука", - "value_source.mode": "Режим:", - "value_source.mode.hint": "RMS измеряет среднюю громкость. Пик отслеживает самые громкие моменты. Бит реагирует на ритм.", - "value_source.mode.rms": "RMS (Громкость)", - "value_source.mode.peak": "Пик", - "value_source.mode.beat": "Бит", - "value_source.mode.rms.desc": "Средний уровень громкости", - "value_source.mode.peak.desc": "Отслеживание пиковых моментов", - "value_source.mode.beat.desc": "Детекция ритмических ударов", - "value_source.auto_gain": "Авто-усиление:", - "value_source.auto_gain.hint": "Автоматически нормализует уровни звука, чтобы выходное значение использовало полный диапазон независимо от громкости входного сигнала", - "value_source.auto_gain.enable": "Включить авто-усиление", - "value_source.sensitivity": "Чувствительность:", - "value_source.sensitivity.hint": "Множитель усиления аудиосигнала (выше = более реактивный)", - "value_source.scene_sensitivity.hint": "Множитель усиления сигнала яркости (выше = более чувствительный к изменениям яркости)", - "value_source.smoothing": "Сглаживание:", - "value_source.smoothing.hint": "Временное сглаживание (0 = мгновенный отклик, 1 = очень плавный/медленный)", - "value_source.audio_min_value": "Мин. значение:", - "value_source.audio_min_value.hint": "Выход при тишине (напр. 0.3 = минимум 30% яркости)", - "value_source.audio_max_value": "Макс. значение:", - "value_source.audio_max_value.hint": "Выход при максимальном уровне звука", - "value_source.schedule": "Расписание:", - "value_source.schedule.hint": "Определите минимум 2 временные точки. Яркость линейно интерполируется между ними, с переходом через полночь.", - "value_source.schedule.add": "+ Добавить точку", - "value_source.schedule.points": "точек", - "value_source.picture_source": "Источник изображения:", - "value_source.picture_source.hint": "Источник изображения, кадры которого будут анализироваться на среднюю яркость.", - "value_source.scene_behavior": "Поведение:", - "value_source.scene_behavior.hint": "Дополнение: тёмная сцена = высокая яркость (для фоновой подсветки). Совпадение: яркая сцена = высокая яркость.", - "value_source.scene_behavior.complement": "Дополнение (тёмный → ярко)", - "value_source.scene_behavior.match": "Совпадение (яркий → ярко)", - "value_source.adaptive_min_value": "Мин. значение:", - "value_source.adaptive_min_value.hint": "Минимальная выходная яркость", - "value_source.adaptive_max_value": "Макс. значение:", - "value_source.adaptive_max_value.hint": "Максимальная выходная яркость", - "value_source.error.schedule_min": "Расписание требует минимум 2 временные точки", - "value_source.description": "Описание (необязательно):", - "value_source.description.placeholder": "Опишите этот источник значений...", - "value_source.description.hint": "Необязательные заметки об этом источнике значений", - "value_source.created": "Источник значений создан", - "value_source.updated": "Источник значений обновлён", - "value_source.deleted": "Источник значений удалён", - "value_source.delete.confirm": "Удалить этот источник значений?", - "value_source.error.name_required": "Введите название", - "value_source.test": "Тест", - "value_source.test.title": "Тест источника значений", - "value_source.test.connecting": "Подключение...", - "value_source.test.error": "Не удалось подключиться", - "value_source.test.current": "Текущее", - "value_source.test.min": "Мин", - "value_source.test.max": "Макс", - "test.frames": "Кадры", - "test.fps": "Кадр/с", - "test.avg_capture": "Сред", - "targets.brightness": "Яркость:", - "targets.brightness.hint": "Множитель яркости (0–1). Можно привязать к источнику значений для динамического управления.", - "targets.brightness_vs": "Источник яркости:", - "targets.brightness_vs.hint": "Необязательный источник значений для динамического управления яркостью каждый кадр (переопределяет яркость устройства)", - "targets.brightness_vs.none": "Нет (яркость устройства)", - "targets.min_brightness_threshold": "Мин. порог яркости:", - "targets.min_brightness_threshold.hint": "Если итоговая яркость (яркость пикселей × яркость устройства/источника) ниже этого значения, светодиоды полностью выключаются (0 = отключено)", - "targets.adaptive_fps": "Адаптивный FPS:", - "targets.adaptive_fps.hint": "Автоматически снижает частоту отправки, когда устройство перестаёт отвечать, и постепенно восстанавливает её при стабилизации. Рекомендуется для WiFi-устройств со слабым сигналом.", - "targets.protocol": "Протокол:", - "targets.protocol.hint": "DDP отправляет пиксели по быстрому UDP (рекомендуется). HTTP использует JSON API — медленнее, но надёжнее, ограничение ~500 LED.", - "targets.power_limit": "Макс. ток (ABL):", - "targets.power_limit.hint": "Ограничивает расчётный ток ленты бюджетом блока питания, чтобы избежать просадок напряжения (сдвиг цвета, мерцание, перезагрузки) на ярких/белых сценах. Укажите номинальный ток вашего БП с запасом. 0 = без ограничения.", - "targets.power_limit.ma_suffix": "мА (0 = без ограничения)", - "targets.power_limit.per_led": "мА на светодиод (полный белый):", - "targets.protocol.ddp": "DDP (UDP)", - "targets.protocol.ddp.desc": "Быстрые UDP-пакеты — рекомендуется", - "targets.protocol.udp": "WLED UDP (realtime)", - "targets.protocol.udp.desc": "Нативный realtime WLED — корректный RGBW и авто-возврат при обрыве потока", - "targets.protocol.http": "HTTP", - "targets.protocol.http.desc": "JSON API — медленнее, ≤500 LED", - "targets.protocol.serial": "Serial", - "search.open": "Поиск (Ctrl+K)", - "search.placeholder": "Поиск... (Ctrl+K)", - "search.loading": "Загрузка...", - "search.no_results": "Ничего не найдено", - "search.group.devices": "Устройства", - "search.group.targets": "LED-цели", - "search.group.kc_targets": "Цели Key Colors", - "search.group.css": "Источники цветных лент", - "search.group.automations": "Автоматизации", - "search.group.streams": "Потоки изображений", - "search.group.capture_templates": "Шаблоны захвата", - "search.group.pp_templates": "Шаблоны постобработки", - "search.group.pattern_templates": "Шаблоны паттернов", - "search.group.audio": "Аудиоисточники", - "search.group.value": "Источники значений", - "search.group.scenes": "Пресеты сцен", - "search.group.cspt": "Шаблоны обработки полос", - "search.group.sync_clocks": "Синхронные часы", - "search.group.actions": "Действия", - "search.action.start": "Запустить", - "search.action.stop": "Остановить", - "search.action.activate": "Активировать", - "search.action.enable": "Включить", - "search.action.disable": "Отключить", - "settings.backup.label": "Резервное копирование", - "settings.backup.hint": "Скачать всю конфигурацию (устройства, цели, потоки, шаблоны, автоматизации) в виде одного JSON-файла.", - "settings.backup.button": "Скачать", - "settings.backup.success": "Резервная копия скачана", - "settings.backup.error": "Ошибка скачивания резервной копии", - "settings.restore.label": "Восстановление конфигурации", - "settings.restore.hint": "Загрузите ранее сохранённый файл резервной копии для замены всей конфигурации. Сервер перезапустится автоматически.", - "settings.restore.button": "Восстановить", - "settings.restore.confirm": "Это заменит ВСЮ конфигурацию и перезапустит сервер. Вы уверены?", - "settings.restore.success": "Конфигурация восстановлена", - "settings.restore.error": "Ошибка восстановления", - "settings.restore.restarting": "Сервер перезапускается...", - "settings.restore.restart_timeout": "Сервер не отвечает. Обновите страницу вручную.", - "settings.restart_server": "Перезапустить сервер", - "settings.restart_confirm": "Перезапустить сервер? Активные цели будут остановлены.", - "settings.restarting": "Перезапуск сервера...", - "settings.button.close": "Закрыть", - "settings.log_level.label": "Уровень логирования", - "settings.log_level.hint": "Изменить подробность логов сервера в реальном времени. DEBUG — максимум деталей, CRITICAL — только критические ошибки.", - "settings.log_level.save": "Применить", - "settings.log_level.saved": "Уровень логирования изменён", - "settings.log_level.save_error": "Не удалось изменить уровень логирования", - "settings.log_level.desc.debug": "Подробный вывод для разработки", - "settings.log_level.desc.info": "Обычные сообщения", - "settings.log_level.desc.warning": "Возможные проблемы", - "settings.log_level.desc.error": "Только ошибки", - "settings.log_level.desc.critical": "Только критические ошибки", - "settings.shutdown_action.label": "Действие при выключении", - "settings.shutdown_action.hint": "Что происходит с LED-целями при остановке сервера. «Остановить цели» — обычная последовательность остановки, устройства с авто-восстановлением восстановят прежнее состояние. «Ничего» — оставить свет таким, каким он был на последнем кадре.", - "settings.shutdown_action.saved": "Действие при выключении сохранено", - "settings.shutdown_action.save_error": "Не удалось сохранить действие при выключении", - "settings.shutdown_action.opt.stop": "Остановить цели", - "settings.shutdown_action.opt.stop_desc": "Обычная остановка (учитывается авто-восстановление устройств)", - "settings.shutdown_action.opt.nothing": "Ничего", - "settings.shutdown_action.opt.nothing_desc": "Оставить свет на последнем кадре", - "settings.daylight_timezone.label": "Часовой пояс дневного цикла", - "settings.daylight_timezone.hint": "IANA часовой пояс, в котором каждый дневной цикл «реального времени» читает настенные часы. Пусто (по умолчанию) — использовать системный часовой пояс сервера.", - "settings.daylight_timezone.saved": "Часовой пояс дневного цикла сохранён", - "settings.daylight_timezone.save_error": "Не удалось сохранить часовой пояс", - "common.daylight_tz.system": "Системный по умолчанию", - "common.daylight_tz.system_desc": "Часы сервера — без переопределения", - "common.daylight_tz.detected": "Определён браузером", - "common.daylight_tz.detected_label": "Авто-определение", - "common.daylight_tz.search": "Поиск по поясам, городам, регионам…", - "common.daylight_tz.region.utc": "Универсальное", - "common.daylight_tz.region.europe": "Европа", - "common.daylight_tz.region.africa": "Африка", - "common.daylight_tz.region.me": "Ближний Восток", - "common.daylight_tz.region.asia": "Азия", - "common.daylight_tz.region.pacific": "Тихий океан", - "common.daylight_tz.region.americas": "Америка", - "settings.auto_backup.label": "Авто-бэкап", - "settings.auto_backup.hint": "Автоматическое создание периодических резервных копий конфигурации. Старые копии удаляются при превышении максимального количества.", - "settings.auto_backup.enable": "Включить авто-бэкап", - "settings.auto_backup.interval_label": "Интервал", - "settings.auto_backup.max_label": "Макс. копий", - "settings.auto_backup.save": "Сохранить настройки", - "settings.auto_backup.saved": "Настройки авто-бэкапа сохранены", - "settings.auto_backup.save_error": "Не удалось сохранить настройки авто-бэкапа", - "settings.auto_backup.backup_now": "Создать бэкап", - "settings.auto_backup.backup_created": "Бэкап создан", - "settings.auto_backup.backup_error": "Ошибка создания бэкапа", - "settings.auto_backup.last_backup": "Последний бэкап", - "settings.auto_backup.never": "Никогда", - "settings.saved_backups.label": "Сохранённые копии", - "settings.saved_backups.hint": "Файлы авто-бэкапа на сервере. Скачайте для локального хранения или удалите для освобождения места.", - "settings.saved_backups.empty": "Нет сохранённых копий", - "settings.saved_backups.restore": "Восстановить", - "settings.saved_backups.download": "Скачать", - "settings.saved_backups.delete": "Удалить", - "settings.saved_backups.delete_confirm": "Удалить эту резервную копию?", - "settings.saved_backups.delete_error": "Не удалось удалить копию", - "settings.saved_backups.type.auto": "авто", - "settings.saved_backups.type.manual": "ручной", - "settings.mqtt.label": "MQTT", - "settings.mqtt.hint": "Настройте подключение к MQTT-брокеру для условий и триггеров автоматизации.", - "settings.mqtt.enabled": "Включить MQTT", - "settings.mqtt.host_label": "Хост брокера", - "settings.mqtt.port_label": "Порт", - "settings.mqtt.username_label": "Имя пользователя", - "settings.mqtt.password_label": "Пароль", - "settings.mqtt.password_set_hint": "Пароль задан — оставьте пустым, чтобы сохранить", - "settings.mqtt.client_id_label": "Идентификатор клиента", - "settings.mqtt.base_topic_label": "Базовый топик", - "settings.mqtt.save": "Сохранить настройки MQTT", - "settings.mqtt.saved": "Настройки MQTT сохранены", - "settings.mqtt.save_error": "Не удалось сохранить настройки MQTT", - "settings.mqtt.error_host_required": "Требуется указать хост брокера", - "settings.logs.label": "Журнал сервера", - "settings.logs.hint": "Просмотр журнала сервера в реальном времени. Используйте фильтр для отображения нужных уровней.", - "settings.logs.connect": "Подключить", - "settings.logs.disconnect": "Отключить", - "settings.logs.clear": "Очистить", - "settings.logs.error": "Ошибка подключения к журналу", - "settings.logs.filter.all": "Все уровни", - "settings.logs.filter.info": "Info+", - "settings.logs.filter.warning": "Warning+", - "settings.logs.filter.error": "Только ошибки", - "settings.logs.filter.all_desc": "Все сообщения лога", - "settings.logs.filter.info_desc": "Info, предупреждения и ошибки", - "settings.logs.filter.warning_desc": "Только предупреждения и ошибки", - "settings.logs.filter.error_desc": "Только ошибки", - "settings.logs.stat.lines": "СТРОК", - "settings.logs.stat.warn": "ВНИМ", - "settings.logs.stat.err": "ОШИБ", - "settings.logs.patch.idle": "ОЖИДАНИЕ", - "settings.logs.patch.connecting": "ПОДКЛЮЧЕНИЕ", - "settings.logs.patch.live": "ПОТОК", - "settings.logs.patch.error": "ОФЛАЙН", - "settings.logs.empty.title": "Ожидание журналов", - "settings.logs.empty.sub": "Подключите WebSocket-поток, чтобы начать трансляцию.", - "device.error.power_off_failed": "Не удалось выключить устройство", - "device.error.remove_failed": "Не удалось удалить устройство", - "device.error.settings_load_failed": "Не удалось загрузить настройки устройства", - "device.error.brightness": "Не удалось обновить яркость", - "device.error.required": "Пожалуйста, заполните все поля", - "device.error.update": "Не удалось обновить устройство", - "device.error.save": "Не удалось сохранить настройки", - "device.error.clone_failed": "Не удалось клонировать устройство", - "device.error.load_failed": "Не удалось загрузить устройство", - "device_discovery.error.fill_all_fields": "Пожалуйста, заполните все поля", - "device_discovery.added": "Устройство успешно добавлено", - "device_discovery.error.add_failed": "Не удалось добавить устройство", - "calibration.error.load_failed": "Не удалось загрузить калибровку", - "calibration.error.css_load_failed": "Не удалось загрузить источник цветовой полосы", - "calibration.error.test_toggle_failed": "Не удалось переключить тестовый край", - "calibration.error.save_failed": "Не удалось сохранить калибровку", - "calibration.error.led_count_mismatch": "Общее количество LED должно совпадать с количеством LED устройства", - "calibration.error.led_count_exceeded": "Калиброванных LED больше, чем общее количество LED", - "calibration.mode.simple": "Простой", - "calibration.mode.advanced": "Расширенный", - "calibration.switch_to_advanced": "Расширенный режим", - "calibration.advanced.title": "Расширенная калибровка", - "calibration.advanced.switch_to_simple": "Простой режим", - "calibration.advanced.lines_title": "Линии", - "calibration.advanced.canvas_hint": "Перетаскивайте мониторы. Нажимайте на грани для выбора линий. Прокрутка — масштаб, перетаскивание пустого места — сдвиг.", - "calibration.advanced.reset_view": "Сбросить вид", - "calibration.advanced.line_properties": "Свойства линии", - "calibration.advanced.picture_source": "Источник:", - "calibration.advanced.picture_source.hint": "Источник изображения (монитор), с которого эта линия снимает данные", - "calibration.advanced.edge": "Грань:", - "calibration.advanced.edge.hint": "С какой грани экрана снимать пиксели", - "calibration.advanced.led_count": "Светодиоды:", - "calibration.advanced.led_count.hint": "Количество светодиодов на этой линии", - "calibration.advanced.span_start": "Начало:", - "calibration.advanced.span_start.hint": "Откуда начинается захват вдоль грани (0 = начало, 1 = конец). Позволяет покрыть только часть грани.", - "calibration.advanced.span_end": "Конец:", - "calibration.advanced.span_end.hint": "Где заканчивается захват вдоль грани (0 = начало, 1 = конец). Вместе с «Начало» определяет активный участок.", - "calibration.advanced.border_width": "Глубина (пкс):", - "calibration.advanced.border_width.hint": "Сколько пикселей вглубь от края захватывать. Большие значения берут больше внутренней части экрана.", - "calibration.advanced.reverse": "Реверс", - "calibration.advanced.no_lines_warning": "Добавьте хотя бы одну линию", - "dashboard.error.automation_toggle_failed": "Не удалось переключить автоматизацию", - "dashboard.error.start_failed": "Не удалось запустить обработку", - "dashboard.error.stop_failed": "Не удалось остановить обработку", - "dashboard.error.stop_all": "Не удалось остановить все цели", - "target.error.editor_open_failed": "Не удалось открыть редактор цели", - "target.error.start_failed": "Не удалось запустить цель", - "target.error.stop_failed": "Не удалось остановить цель", - "target.error.clone_failed": "Не удалось клонировать цель", - "target.error.delete_failed": "Не удалось удалить цель", - "target.error.load_failed": "Не удалось загрузить цель", - "targets.stop_all.button": "Остановить все", - "targets.stop_all.none_running": "Нет запущенных целей", - "targets.stop_all.stopped": "Остановлено целей: {count}", - "targets.stop_all.error": "Не удалось остановить цели", - "audio_source.error.load": "Не удалось загрузить аудиоисточник", - "audio_template.error.clone_failed": "Не удалось клонировать аудиошаблон", - "value_source.error.load": "Не удалось загрузить источник значений", - "color_strip.error.editor_open_failed": "Не удалось открыть редактор цветовой полосы", - "color_strip.error.clone_failed": "Не удалось клонировать источник цветовой полосы", - "color_strip.error.delete_failed": "Не удалось удалить источник цветовой полосы", - "pattern.error.editor_open_failed": "Не удалось открыть редактор шаблона узоров", - "pattern.error.clone_failed": "Не удалось клонировать шаблон узоров", - "pattern.error.delete_failed": "Не удалось удалить шаблон узоров", - "pattern.error.capture_bg_failed": "Не удалось захватить фон", - "pattern.error.save_failed": "Не удалось сохранить шаблон узоров", - "stream.error.clone_picture_failed": "Не удалось клонировать источник изображения", - "stream.error.clone_capture_failed": "Не удалось клонировать шаблон захвата", - "stream.error.clone_pp_failed": "Не удалось клонировать шаблон постобработки", - "theme.switched.dark": "Переключено на тёмную тему", - "theme.switched.light": "Переключено на светлую тему", - "theme.switched.system": "Переключено на системную тему", - "accent.color.updated": "Цвет акцента обновлён", - "search.footer": "↑↓ навигация · Enter выбор · Esc закрыть", - "sync_clock.group.title": "Часы синхронизации", - "sync_clock.add": "Добавить часы", - "sync_clock.edit": "Редактировать часы", - "sync_clock.name": "Название:", - "sync_clock.name.placeholder": "Основные часы анимации", - "sync_clock.name.hint": "Описательное название для этих часов синхронизации", - "sync_clock.speed": "Скорость:", - "sync_clock.speed.hint": "Множитель скорости анимации для всех привязанных источников. 1.0 = обычная, 2.0 = двойная, 0.5 = половинная.", - "sync_clock.description": "Описание (необязательно):", - "sync_clock.description.placeholder": "Необязательное описание", - "sync_clock.description.hint": "Необязательные заметки о назначении этих часов", - "sync_clock.status.running": "Работает", - "sync_clock.status.paused": "Приостановлено", - "sync_clock.action.pause": "Приостановить", - "sync_clock.action.resume": "Возобновить", - "sync_clock.action.reset": "Сбросить", - "sync_clock.error.name_required": "Название часов обязательно", - "sync_clock.error.load": "Не удалось загрузить часы синхронизации", - "sync_clock.created": "Часы синхронизации созданы", - "sync_clock.updated": "Часы синхронизации обновлены", - "sync_clock.deleted": "Часы синхронизации удалены", - "sync_clock.paused": "Часы приостановлены", - "sync_clock.resumed": "Часы возобновлены", - "sync_clock.reset_done": "Часы сброшены на ноль", - "sync_clock.delete.confirm": "Удалить эти часы синхронизации? Привязанные источники потеряют синхронизацию и будут работать на скорости по умолчанию.", - "sync_clock.elapsed": "Прошло времени", - "weather_source.group.title": "Источники погоды", - "weather_source.add": "Добавить источник погоды", - "weather_source.edit": "Редактировать источник погоды", - "weather_source.name": "Название:", - "weather_source.name.placeholder": "Моя погода", - "weather_source.name.hint": "Описательное название источника погоды", - "weather_source.provider": "Провайдер:", - "weather_source.provider.hint": "Провайдер метеоданных. Open-Meteo бесплатный и не требует API ключа.", - "weather_source.provider.open_meteo.desc": "Бесплатно, без API ключа", - "weather_source.location": "Местоположение:", - "weather_source.location.hint": "Географические координаты. Используйте автоопределение или введите вручную.", - "weather_source.latitude": "Шир:", - "weather_source.longitude": "Долг:", - "weather_source.use_my_location": "Определить", - "weather_source.update_interval": "Интервал обновления:", - "weather_source.update_interval.hint": "Частота запроса метеоданных. Меньше = более оперативные обновления.", - "weather_source.description": "Описание (необязательно):", - "weather_source.description.placeholder": "Необязательное описание", - "weather_source.test": "Тест", - "weather_source.error.name_required": "Название источника погоды обязательно", - "weather_source.error.load": "Не удалось загрузить источник погоды", - "weather_source.created": "Источник погоды создан", - "weather_source.updated": "Источник погоды обновлён", - "weather_source.deleted": "Источник погоды удалён", - "weather_source.delete.confirm": "Удалить этот источник погоды? Связанные источники цветовых лент потеряют данные о погоде.", - "weather_source.geo.success": "Местоположение определено", - "weather_source.geo.error": "Ошибка геолокации", - "weather_source.geo.not_supported": "Геолокация не поддерживается вашим браузером", - "streams.group.weather": "Погода", - "section.empty.weather_sources": "Нет источников погоды. Нажмите + для добавления.", - "color_strip.clock": "Часы синхронизации:", - "color_strip.clock.hint": "Привязка к часам для синхронизации анимации между источниками. Скорость управляется на часах.", - "graph.title": "Граф", - "graph.fit_all": "Показать все узлы", - "graph.zoom_in": "Приблизить", - "graph.zoom_out": "Отдалить", - "graph.search": "Поиск узлов", - "graph.search_placeholder": "Поиск сущностей...", - "graph.legend": "Легенда", - "graph.minimap": "Миникарта", - "graph.relayout": "Перестроить", - "graph.empty": "Ещё нет сущностей", - "graph.empty.hint": "Создайте устройства, источники и цели, чтобы увидеть их здесь.", - "graph.disconnect": "Отключить", - "graph.connection_updated": "Соединение обновлено", - "graph.connection_failed": "Не удалось обновить соединение", - "graph.connection_removed": "Соединение удалено", - "graph.disconnect_failed": "Не удалось отключить", - "graph.relayout_confirm": "Сбросить все ручные позиции узлов и перестроить граф?", - "graph.fullscreen": "Полноэкранный режим", - "graph.add_entity": "Добавить сущность", - "graph.color_picker": "Цвет узла", - "graph.filter": "Фильтр узлов", - "graph.filter_placeholder": "Фильтр по имени...", - "graph.filter_clear": "Очистить фильтр", - "graph.filter_running": "Запущен", - "graph.filter_stopped": "Остановлен", - "graph.filter_types": "Типы", - "graph.filter_group.capture": "Захват", - "graph.filter_group.strip": "Цвет. полосы", - "graph.filter_group.audio": "Аудио", - "graph.filter_group.targets": "Цели", - "graph.filter_group.other": "Другое", - "graph.bulk_delete_confirm": "Удалить {count} выбранных сущностей?", - "graph.nothing_to_undo": "Нечего отменять", - "graph.nothing_to_redo": "Нечего повторять", - "graph.help_title": "Горячие клавиши", - "graph.help.search": "Поиск", - "graph.help.filter": "Фильтр", - "graph.help.add": "Добавить сущность", - "graph.help.shortcuts": "Горячие клавиши", - "graph.help.delete": "Удалить / Отсоединить", - "graph.help.select_all": "Выбрать все", - "graph.help.undo": "Отменить", - "graph.help.redo": "Повторить", - "graph.help.fullscreen": "Полный экран", - "graph.help.deselect": "Снять выбор", - "graph.help.navigate": "Навигация по узлам", - "graph.help.click": "Клик", - "graph.help.click_desc": "Выбрать узел", - "graph.help.dblclick": "Двойной клик", - "graph.help.dblclick_desc": "Приблизить к узлу", - "graph.help.shift_click": "Shift+Клик", - "graph.help.shift_click_desc": "Множественный выбор", - "graph.help.shift_drag": "Shift+Перетащить", - "graph.help.shift_drag_desc": "Выбор рамкой", - "graph.help.drag_node": "Перетащить узел", - "graph.help.drag_node_desc": "Переместить", - "graph.help.drag_port": "Перетащить порт", - "graph.help.drag_port_desc": "Соединить сущности", - "graph.help.right_click": "ПКМ по связи", - "graph.help.right_click_desc": "Отсоединить связь", - "graph.tooltip.fps": "FPS", - "graph.tooltip.errors": "Ошибки", - "graph.tooltip.uptime": "Время работы", - "graph.undone": "Отменено", - "graph.redone": "Повторено", - "graph.action.connect": "Соединить", - "graph.action.disconnect": "Отсоединить", - "graph.action.move": "Переместить узел", - "graph.action.rewire": "Переподключить слот", - "graph.choose_connection": "Выберите соединение", - "graph.rewire": "Переподключить…", - "graph.rewire_choose_source": "Выберите новый источник", - "graph.issues": "Проблемы", - "graph.issues_none": "Проблем не найдено", - "graph.issue.broken_ref": "Битая ссылка: {field}", - "graph.issue.cycle": "Входит в цикл зависимостей", - "graph.replace_connection_confirm": "Заменить существующее соединение?", - "graph.no_compatible_connection": "Нет совместимого соединения между этими объектами", - "graph.create_and_connect": "Создать и соединить…", - "graph.export": "Экспорт графа (JSON)", - "graph.export_done": "Граф экспортирован", - "graph.export_failed": "Не удалось экспортировать граф", - "graph.duplicate": "Дублировать выбранное", - "graph.duplicate_none": "Выберите один или несколько узлов для дублирования", - "graph.duplicate_none_eligible": "В выборе нечего дублировать (только источники значений и цветовых лент)", - "graph.duplicate_done": "Продублировано источников: {count}", - "graph.duplicate_done_warn": "Продублировано источников: {count} — часть ссылок не удалось перепривязать", - "graph.duplicate_failed": "Не удалось дублировать выбранное", - "graph.delete_with_dependents_confirm": "Этот объект используется {count} другими: {names}. Удалить и разорвать эти связи?", - "automation.enabled": "Автоматизация включена", - "automation.disabled": "Автоматизация выключена", - "scene_preset.activated": "Пресет активирован", - "scene_preset.used_by": "Используется в %d автоматизации(ях)", - "settings.api_keys.label": "API-ключи", - "settings.api_keys.hint": "API-ключи определяются в конфигурационном файле сервера (config.yaml). Отредактируйте файл и перезапустите сервер для применения изменений.", - "settings.api_keys.empty": "API-ключи не настроены", - "settings.api_keys.load_error": "Не удалось загрузить API-ключи", - "settings.partial.label": "Частичный экспорт / импорт", - "settings.partial.hint": "Экспортировать или импортировать один тип объектов. Импорт заменяет или объединяет данные и перезапускает сервер.", - "settings.partial.store.devices": "Устройства", - "settings.partial.store.output_targets": "LED-цели", - "settings.partial.store.color_strip_sources": "Цветные полосы", - "settings.partial.store.picture_sources": "Источники изображений", - "settings.partial.store.audio_sources": "Аудио-источники", - "settings.partial.store.audio_templates": "Аудио-шаблоны", - "settings.partial.store.capture_templates": "Шаблоны захвата", - "settings.partial.store.postprocessing_templates": "Шаблоны постобработки", - "settings.partial.store.color_strip_processing_templates": "Шаблоны обработки полос", - "settings.partial.store.pattern_templates": "Шаблоны паттернов", - "settings.partial.store.value_sources": "Источники значений", - "settings.partial.store.sync_clocks": "Синхронные часы", - "settings.partial.store.automations": "Автоматизации", - "settings.partial.store.scene_presets": "Пресеты сцен", - "settings.partial.export_button": "Экспорт", - "settings.partial.import_button": "Импорт из файла", - "settings.partial.merge_label": "Объединить (добавить/перезаписать, сохранить существующие)", - "settings.partial.export_success": "Экспорт выполнен", - "settings.partial.export_error": "Ошибка экспорта", - "settings.partial.import_success": "Импорт выполнен", - "settings.partial.import_error": "Ошибка импорта", - "settings.partial.import_confirm_replace": "Это ЗАМЕНИТ все данные {store} и перезапустит сервер. Продолжить?", - "settings.partial.import_confirm_merge": "Это ОБЪЕДИНИТ данные {store} и перезапустит сервер. Продолжить?", - "section.empty.devices": "Устройств пока нет. Нажмите + для добавления.", - "section.empty.targets": "LED-целей пока нет. Нажмите + для добавления.", - "section.empty.kc_targets": "Целей ключевых цветов пока нет. Нажмите + для добавления.", - "section.empty.pattern_templates": "Шаблонов паттернов пока нет. Нажмите + для добавления.", - "section.empty.picture_sources": "Источников пока нет. Нажмите + для добавления.", - "section.empty.capture_templates": "Шаблонов захвата пока нет. Нажмите + для добавления.", - "section.empty.pp_templates": "Шаблонов постобработки пока нет. Нажмите + для добавления.", - "section.empty.audio_sources": "Аудио-источников пока нет. Нажмите + для добавления.", - "section.empty.audio_templates": "Аудио-шаблонов пока нет. Нажмите + для добавления.", - "section.empty.color_strips": "Цветных полос пока нет. Нажмите + для добавления.", - "section.empty.value_sources": "Источников значений пока нет. Нажмите + для добавления.", - "section.empty.sync_clocks": "Синхронных часов пока нет. Нажмите + для добавления.", - "section.empty.cspt": "Шаблонов обработки полос пока нет. Нажмите + для добавления.", - "section.empty.automations": "Автоматизаций пока нет. Нажмите + для добавления.", - "section.empty.scenes": "Пресетов сцен пока нет. Нажмите + для добавления.", - "section.empty.playlists": "Плейлистов пока нет. Нажмите + для добавления.", - "bulk.select": "Выбрать", - "bulk.cancel": "Отмена", - "bulk.selected_count.one": "{count} выбран", - "bulk.selected_count.few": "{count} выбрано", - "bulk.selected_count.many": "{count} выбрано", - "bulk.select_all": "Выбрать все", - "bulk.deselect_all": "Снять выбор", - "bulk.delete": "Удалить", - "bulk.start": "Запустить", - "bulk.stop": "Остановить", - "bulk.enable": "Включить", - "bulk.disable": "Выключить", - "bulk.confirm_delete.one": "Удалить {count} элемент?", - "bulk.confirm_delete.few": "Удалить {count} элемента?", - "bulk.confirm_delete.many": "Удалить {count} элементов?", - "appearance.style.label": "Стили оформления", - "appearance.style.hint": "Выберите визуальную тему — шрифт и цветовая палитра применяются вместе.", - "appearance.preset.default": "Стандарт", - "appearance.preset.midnight": "Полночь", - "appearance.preset.ember": "Угли", - "appearance.preset.arctic": "Арктика", - "appearance.preset.terminal": "Терминал", - "appearance.preset.neon": "Неон", - "appearance.preset.sakura": "Сакура", - "appearance.preset.ocean": "Океан", - "appearance.preset.copper": "Медь", - "appearance.preset.vapor": "Вейпорвейв", - "appearance.preset.monolith": "Монолит", - "appearance.preset.applied": "Стиль применён", - "appearance.bg.label": "Фоновые эффекты", - "appearance.bg.hint": "Добавьте фоновый слой за интерфейсом.", - "appearance.bg.none": "Нет", - "appearance.bg.noise": "Шумовое поле", - "appearance.bg.aurora": "Северное сияние", - "appearance.bg.plasma": "Плазма", - "appearance.bg.rain": "Цифровой дождь", - "appearance.bg.stars": "Звёздное поле", - "appearance.bg.warp": "Тоннель", - "appearance.bg.grid": "Точечная сетка", - "appearance.bg.mesh": "Градиент", - "appearance.bg.scanlines": "Развёртка", - "appearance.bg.applied": "Фоновый эффект применён", - "settings.tab.notifications": "Уведомления", - "settings.tab.updates": "Обновления", - "settings.tab.about": "О программе", - "settings.notifications.intro_label": "События устройств", - "settings.notifications.intro_hint": "Выберите способ оповещения для каждого события. «Снэк» — всплывающее сообщение внутри приложения, «ОС» — системное уведомление (требуется разрешение в браузере), «Оба» — оба варианта, «Выкл» — событие игнорируется.", - "settings.notifications.row.online": "Устройство вернулось в сеть", - "settings.notifications.row.offline": "Устройство пропало", - "settings.notifications.row.discovered": "Новое устройство найдено", - "settings.notifications.row.lost": "Найденное устройство исчезло", - "settings.notifications.background.label": "Фоновое обнаружение", - "settings.notifications.background.hint": "Постоянно сканировать локальную сеть (mDNS) и последовательные порты на новые LED-устройства. Отключите, чтобы убрать события «найдено/потеряно» в источнике. Перезапустите сервер, чтобы применить.", - "settings.notifications.background.toggle": "Включить фоновое обнаружение", - "settings.notifications.permission.label": "Разрешение на уведомления ОС", - "settings.notifications.permission.grant": "Разрешить", - "settings.notifications.permission.granted": "Системные уведомления включены", - "settings.notifications.permission.denied": "Системные уведомления заблокированы — измените в настройках браузера", - "settings.notifications.permission.state.granted": "Разрешено — будут показываться уведомления ОС", - "settings.notifications.permission.state.denied": "Запрещено — измените в настройках браузера", - "settings.notifications.permission.state.default": "Разрешение ещё не запрошено", - "settings.notifications.permission.hint": "Браузер управляет разрешением на системные уведомления для каждого сайта отдельно. После отказа LedGrab уже не может запросить разрешение снова — его нужно сбросить в браузере. Нажмите на значок сайта (замок) в адресной строке → Настройки сайта → Уведомления → Разрешить, затем перезагрузите страницу.", - "settings.notifications.test_button": "Отправить тестовое уведомление", - "settings.notifications.saved": "Настройки уведомлений сохранены", - "settings.notifications.save_error": "Не удалось сохранить настройки уведомлений", - "settings.notifications.channel.none.label": "Выкл", - "settings.notifications.channel.none.desc": "Не уведомлять об этом событии", - "settings.notifications.channel.snack.label": "Снэк", - "settings.notifications.channel.snack.desc": "Всплывающее сообщение внизу страницы", - "settings.notifications.channel.os.label": "ОС", - "settings.notifications.channel.os.desc": "Системное уведомление (работает в фоне браузера)", - "settings.notifications.channel.both.label": "Оба", - "settings.notifications.channel.both.desc": "Снэк и системное уведомление", - "settings.notifications.permission.pill.granted": "РАЗРЕШЕНО", - "settings.notifications.permission.pill.denied": "ЗАБЛОКИРОВАНО", - "settings.rail.group.workspace": "Рабочая зона", - "settings.rail.group.system": "Система", - "settings.save_bar.unsaved": "Несохранённые изменения в поле", - "settings.save_bar.revert": "Отменить", - "settings.save_bar.save": "Сохранить", - "settings.section.api_keys": "Идентификация и API", - "settings.section.server": "Сервер", - "settings.section.lifecycle": "Жизненный цикл", - "settings.section.destructive": "ОПАСНО", - "settings.section.manual": "Вручную", - "settings.section.notif_channels": "Каналы", - "settings.section.notif_discovery": "Обнаружение", - "settings.section.notif_permission": "Разрешение ОС", - "settings.api_keys.read_only": "Только чтение", - "settings.api_keys.meta.one": "ключ", - "settings.api_keys.meta.many": "ключей", - "settings.logs.sub": "Живой поток лога сервера с фильтром по уровню. Открывается в полноэкранном слое.", - "settings.restart.sub": "Перезапустить процесс LedGrab. Захват и подключенные устройства приостановятся примерно на 3 секунды.", - "settings.restart.button": "Перезапустить", - "settings.notif_matrix.col.event": "Событие", - "settings.notif_matrix.event_count": "4 СОБЫТИЯ", - "settings.auto_backup.pill.running": "АКТИВНО", - "update.pill.available": "ДОСТУПНО ОБНОВЛЕНИЕ", - "update.pill.error": "ОШИБКА", - "update.pill.updated": "АКТУАЛЬНО", - "notifications.unknown_device": "Неизвестное устройство", - "notifications.device_online.title": "Устройство в сети", - "notifications.device_online.body": "{device} снова в сети", - "notifications.device_offline.title": "Устройство недоступно", - "notifications.device_offline.body": "{device} перестало отвечать", - "notifications.device_discovered.title": "Новое устройство", - "notifications.device_discovered.body": "{device} появилось в сети", - "notifications.device_lost.title": "Устройство пропало", - "notifications.device_lost.body": "{device} исчезло", - "notifications.bulk.title": "Изменилось несколько устройств", - "notifications.bulk.body": "Одновременно произошло {count} событий устройств", - "notifications.test.title": "Тестовое уведомление LedGrab", - "notifications.test.body": "Уведомления работают корректно.", - "update.status_label": "Статус обновления", - "update.current_version": "Текущая версия:", - "update.badge_tooltip": "Доступна новая версия — нажмите для подробностей", - "update.available": "Доступна версия {version}", - "update.up_to_date": "Установлена последняя версия", - "update.prerelease": "пре-релиз", - "update.view_release": "Подробнее", - "update.dismiss": "Скрыть", - "update.check_now": "Проверить обновления", - "update.check_error": "Ошибка проверки обновлений", - "update.last_check": "Последняя проверка", - "update.never": "никогда", - "update.release_notes": "Примечания к релизу", - "update.view_release_notes": "Открыть примечания к релизу", - "update.release_notes_hint": "Что нового в доступной версии — прочитайте список изменений перед применением.", - "update.release_notes_open": "Открыть", - "update.assets.title": "Загрузки", - "update.assets.desc.windows_installer": "Установщик Windows — ярлык в меню «Пуск», опциональный автозапуск, деинсталлятор", - "update.assets.desc.windows_portable": "Windows portable — распакуйте в любую папку и запустите LedGrab.bat", - "update.assets.desc.windows_msi": "MSI-установщик Windows", - "update.assets.desc.windows_exe": "Исполняемый файл Windows", - "update.assets.desc.linux_tarball": "Архив Linux — распакуйте и запустите ./run.sh", - "update.assets.desc.linux_appimage": "Linux portable — один исполняемый файл", - "update.assets.desc.linux_deb": "Пакет Debian / Ubuntu", - "update.assets.desc.linux_rpm": "Пакет Fedora / RHEL", - "update.assets.desc.linux_sandbox": "Sandbox-пакет Linux", - "update.assets.desc.macos_dmg": "Образ диска macOS — перетащите в «Программы»", - "update.assets.desc.macos_installer": "Установщик macOS", - "update.assets.desc.android": "Android — sideload на Android 7.0+", - "update.assets.desc.android_bundle": "Android App Bundle (Play Store)", - "update.assets.desc.ios": "Приложение iOS", - "update.assets.desc.zip_archive": "Архив ZIP", - "update.assets.desc.tarball": "Tar-архив", - "update.assets.desc.archive": "Сжатый архив", - "update.auto_check_label": "Автоматическая проверка", - "update.auto_check_hint": "Периодически проверять наличие новых версий в фоновом режиме.", - "update.enable": "Включить автопроверку", - "update.interval_label": "Интервал проверки", - "update.channel_label": "Канал", - "update.channel.stable": "Стабильный", - "update.channel.stable_desc": "Только стабильные релизы", - "update.channel.prerelease": "Пре-релиз", - "update.channel.prerelease_desc": "Включая альфа, бета и RC сборки", - "update.save_settings": "Сохранить настройки", - "update.settings_saved": "Настройки обновлений сохранены", - "update.settings_save_error": "Не удалось сохранить настройки обновлений", - "update.apply_now": "Обновить сейчас", - "update.apply_confirm": "Скачать и установить версию {version}? Сервер будет перезапущен автоматически.", - "update.apply_error": "Ошибка обновления", - "update.applying": "Применяется обновление…", - "update.downloading": "Загрузка…", - "update.install_type_label": "Тип установки:", - "update.install_type.installer": "Установщик Windows", - "update.install_type.portable": "Портативная", - "update.install_type.docker": "Docker", - "update.install_type.dev": "Разработка", - "color_strip.notification.search_apps": "Поиск приложений…", - "asset.group.title": "Ресурсы", - "asset.upload": "Загрузить ресурс", - "asset.edit": "Редактировать ресурс", - "asset.name": "Название:", - "asset.name.hint": "Отображаемое название ресурса.", - "asset.description": "Описание:", - "asset.description.hint": "Необязательное описание ресурса.", - "asset.file": "Файл:", - "asset.file.hint": "Выберите файл для загрузки (звук, изображение, видео или другое).", - "asset.drop_or_browse": "Перетащите файл сюда или нажмите для выбора", - "asset.uploaded": "Ресурс загружен", - "asset.updated": "Ресурс обновлён", - "asset.deleted": "Ресурс удалён", - "asset.confirm_delete": "Удалить этот ресурс?", - "asset.error.name_required": "Название обязательно", - "asset.error.no_file": "Выберите файл для загрузки", - "asset.error.delete_failed": "Не удалось удалить ресурс", - "asset.error.play_failed": "Не удалось воспроизвести звук", - "asset.error.download_failed": "Не удалось скачать ресурс", - "asset.play": "Воспроизвести", - "asset.download": "Скачать", - "asset.prebuilt": "Встроенный", - "asset.prebuilt_restored": "Восстановлено встроенных ресурсов: {count}", - "asset.prebuilt_none_to_restore": "Все встроенные ресурсы уже доступны", - "asset.restore_prebuilt": "Восстановить встроенные звуки", - "asset.type.sound": "Звук", - "asset.type.image": "Изображение", - "asset.type.video": "Видео", - "asset.type.other": "Другое", - "streams.group.assets": "Ресурсы", - "section.empty.assets": "Ресурсов пока нет. Нажмите +, чтобы загрузить.", - "donation.message": "LedGrab — бесплатный проект с открытым кодом. Если он вам полезен, поддержите разработку.", - "donation.support": "Поддержать проект", - "donation.view_source": "Исходный код", - "donation.later": "Напомнить позже", - "donation.dismiss": "Больше не показывать", - "donation.about_title": "О LedGrab", - "donation.about_opensource": "LedGrab — программа с открытым исходным кодом, бесплатная для использования и модификации.", - "donation.about_donate": "Поддержать разработку", - "donation.about_license": "Лицензия MIT", - "donation.about_author": "Создатель —", - "streams.group.game": "Игровая интеграция", - "tree.group.game": "Игры", - "game_integration.section_title": "Игровые интеграции", - "section.empty.game_integrations": "Нет игровых интеграций. Нажмите +, чтобы создать.", - "game_integration.add": "Добавить игровую интеграцию", - "game_integration.edit": "Редактировать игровую интеграцию", - "game_integration.created": "Игровая интеграция создана", - "game_integration.updated": "Игровая интеграция обновлена", - "game_integration.deleted": "Игровая интеграция удалена", - "game_integration.confirm_delete": "Удалить эту игровую интеграцию?", - "game_integration.error.name_required": "Требуется имя", - "game_integration.error.save_failed": "Не удалось сохранить игровую интеграцию", - "game_integration.error.delete_failed": "Не удалось удалить игровую интеграцию", - "game_integration.error.save_first": "Сначала сохраните интеграцию для проверки соединения", - "game_integration.name": "Имя:", - "game_integration.name.hint": "Описательное имя для этой игровой интеграции", - "game_integration.description": "Описание:", - "game_integration.description.hint": "Необязательное описание назначения интеграции", - "game_integration.enabled": "Включено", - "game_integration.adapter_type": "Игра / Адаптер:", - "game_integration.adapter_type.hint": "Выберите тип игры или адаптера", - "game_integration.adapter_config": "Конфигурация адаптера", - "game_integration.no_config": "Конфигурация для этого адаптера не требуется.", - "game_integration.setup_instructions": "Инструкции по настройке", - "game_integration.setup_instructions.hint": "Следуйте этим шагам для настройки отправки данных из игры", - "game_integration.event_mappings": "Привязка событий", - "game_integration.event_mappings.hint": "Привяжите игровые события к LED-эффектам. Каждый тип события может вызывать свой визуальный эффект.", - "game_integration.mapping.add": "+ Добавить привязку", - "game_integration.mapping.event_type": "Событие", - "game_integration.mapping.effect_type": "Эффект", - "game_integration.mapping.color": "Цвет", - "game_integration.mapping.duration": "Длительность (мс)", - "game_integration.mapping.intensity": "Интенсивность", - "game_integration.mapping.priority": "Приоритет", - "game_integration.mapping.select_preset": "Загрузить пресет...", - "game_integration.preset.select": "Загрузить пресет...", - "game_integration.preset.fps_combat": "FPS Бой", - "game_integration.preset.moba_health": "MOBA Здоровье", - "game_integration.adapter": "Адаптер", - "game_integration.status": "Статус", - "game_integration.status.active": "Активна", - "game_integration.status.inactive": "Неактивна", - "game_integration.mappings": "Привязки", - "game_integration.events.title": "События в реальном времени", - "game_integration.events.waiting": "Ожидание событий...", - "game_integration.events.monitor": "Монитор событий", - "game_integration.test.button": "Тестировать соединение", - "game_integration.test.waiting": "Ожидание событий от игры...", - "game_integration.test.success": "Соединение успешно! Получены события.", - "game_integration.test.error": "Ошибка соединения", - "game_integration.test.timeout": "События не получены за отведённое время.", - "game_integration.auto_setup": "Автонастройка", - "game_integration.auto_setup.success": "Файл конфигурации успешно записан", - "game_integration.auto_setup.failed": "Автонастройка не удалась", - "game_integration.auto_setup.not_supported": "Этот адаптер не поддерживает автонастройку", - "game_integration.auto_setup.game_not_found": "Установка игры не найдена", - "game_integration.auto_setup.token_generated": "Токен авторизации был сгенерирован автоматически", - "game_integration.auto_setup.save_first": "Сначала сохраните интеграцию перед запуском автонастройки", - "color_strip.type.game_event": "Игровое событие", - "color_strip.type.game_event.desc": "LED-эффекты по игровым событиям", - "color_strip.game_event.integration": "Игровая интеграция:", - "color_strip.game_event.integration.hint": "Выберите игровую интеграцию, от которой поступают события.", - "color_strip.game_event.idle_color": "Цвет простоя:", - "color_strip.game_event.idle_color.hint": "Цвет LED, когда нет активных игровых событий.", - "color_strip.game_event.event_mappings": "Привязка событий:", - "color_strip.game_event.event_mappings.hint": "Переопределите или добавьте привязки событий к эффектам для этого источника.", - "color_strip.game_event.error.no_integration": "Выберите игровую интеграцию.", - "color_strip.type.math_wave": "Математическая волна", - "color_strip.type.math_wave.desc": "Генератор математических волн с цветовым градиентом", - "color_strip.math_wave.gradient": "Цветовой градиент:", - "color_strip.math_wave.gradient.hint": "Градиент для окраски волнового выхода. Значения волны отображаются на позиции вдоль этого градиента.", - "color_strip.math_wave.speed": "Скорость:", - "color_strip.math_wave.speed.hint": "Множитель скорости анимации. Более высокие значения ускоряют движение волны.", - "color_strip.math_wave.waves": "Слои волн:", - "color_strip.math_wave.waves.hint": "Добавьте несколько слоёв волн, которые комбинируются вместе. Каждая волна имеет собственную форму, частоту, амплитуду, фазу и смещение.", - "color_strip.math_wave.add_wave": "+ Добавить волну", - "color_strip.math_wave.waveform": "Форма волны", - "color_strip.math_wave.waveform.sine": "Синусоида", - "color_strip.math_wave.waveform.triangle": "Треугольная", - "color_strip.math_wave.waveform.sawtooth": "Пилообразная", - "color_strip.math_wave.waveform.square": "Прямоугольная", - "color_strip.math_wave.frequency": "Частота", - "color_strip.math_wave.amplitude": "Амплитуда", - "color_strip.math_wave.phase": "Фаза", - "color_strip.math_wave.offset": "Смещение", - "color_strip.math_wave.error.no_waves": "Добавьте хотя бы один слой волны.", - "value_source.type.game_event": "Игровое событие", - "value_source.type.game_event.desc": "Игровые метрики (здоровье, патроны, мана) как значения 0-1", - "value_source.game_event.integration": "Игровая интеграция:", - "value_source.game_event.integration.hint": "Выберите игровую интеграцию для этого источника значений.", - "value_source.game_event.event_type": "Тип события:", - "value_source.game_event.event_type.hint": "Непрерывное игровое событие (здоровье, мана, патроны и т.д.).", - "value_source.game_event.min_game_value": "Мин. игровое значение:", - "value_source.game_event.min_game_value.hint": "Исходное игровое значение, соответствующее 0.0.", - "value_source.game_event.max_game_value": "Макс. игровое значение:", - "value_source.game_event.max_game_value.hint": "Исходное игровое значение, соответствующее 1.0.", - "value_source.game_event.smoothing": "Сглаживание:", - "value_source.game_event.smoothing.hint": "Коэффициент EMA-сглаживания. 0 = мгновенно, выше = плавнее.", - "value_source.game_event.default_value": "Значение по умолчанию:", - "value_source.game_event.default_value.hint": "Выходное значение, когда события не поступают в пределах таймаута.", - "value_source.game_event.timeout": "Таймаут (с):", - "value_source.game_event.timeout.hint": "Секунды тишины до возврата к значению по умолчанию.", - "audio_processing.title": "Шаблоны обработки звука", - "audio_processing.add": "Добавить шаблон обработки звука", - "audio_processing.edit": "Редактировать шаблон обработки звука", - "audio_processing.name": "Название шаблона:", - "audio_processing.name.hint": "Описательное название для этого шаблона обработки звука", - "audio_processing.name_placeholder": "Мой шаблон обработки звука", - "audio_processing.description_label": "Описание (необязательно):", - "audio_processing.description.hint": "Опишите назначение этого шаблона", - "audio_processing.description_placeholder": "Опишите этот шаблон...", - "audio_processing.created": "Шаблон обработки звука создан", - "audio_processing.updated": "Шаблон обработки звука обновлён", - "audio_processing.deleted": "Шаблон обработки звука удалён", - "audio_processing.delete.confirm": "Вы уверены, что хотите удалить этот шаблон обработки звука?", - "audio_processing.error.required": "Пожалуйста, заполните все обязательные поля", - "audio_processing.error.load": "Ошибка загрузки шаблона обработки звука", - "audio_processing.error.delete": "Ошибка удаления шаблона обработки звука", - "audio_processing.error.clone_failed": "Не удалось клонировать шаблон обработки звука", - "audio_processing.error.save_failed": "Не удалось сохранить шаблон обработки звука", - "audio_processing.filter_count": "Количество фильтров", - "audio_processing.filters_label": "фильтров", - "streams.group.audio_processing": "Обработка звука", - "section.empty.audio_processing_templates": "Пока нет шаблонов обработки звука. Нажмите +, чтобы создать.", - "audio_source.group.capture": "Захват звука", - "audio_source.group.processed": "Обработанные источники", - "pairing.title": "Сопряжение устройства", - "pairing.instructions.default": "Нажмите и удерживайте кнопку сопряжения на устройстве, затем нажмите «Начать». Устройства обычно открывают окно сопряжения на 30 секунд — действуйте быстро.", - "pairing.start": "Начать сопряжение", - "pairing.pairing": "Сопряжение…", - "pairing.cancel": "Отмена", - "pairing.retry": "Повторить", - "pairing.close": "Закрыть", - "pairing.success": "Успешно сопряжено", - "pairing.not_ready": "Устройство не ответило. Нажмите кнопку сопряжения на устройстве и попробуйте снова.", - "pairing.failed": "Сопряжение не удалось: {detail}", - "pairing.failed_prefix": "Сопряжение не удалось:", - "streams.group.http": "HTTP", - "http_endpoint.group.title": "HTTP-эндпоинты", - "http_endpoint.add": "Добавить HTTP-эндпоинт", - "http_endpoint.edit": "Изменить HTTP-эндпоинт", - "http_endpoint.section.request": "Запрос", - "http_endpoint.section.headers": "Заголовки", - "http_endpoint.name": "Имя:", - "http_endpoint.name.placeholder": "Plex now-playing", - "http_endpoint.name.hint": "Описательное имя для этого эндпоинта", - "http_endpoint.url": "URL:", - "http_endpoint.url.hint": "Полный http(s)-URL для опроса. Локальные адреса разрешены.", - "http_endpoint.method": "Метод:", - "http_endpoint.method.get.desc": "Получить тело ответа.", - "http_endpoint.method.head.desc": "Только код статуса — без тела. Подходит для проверки доступности.", - "http_endpoint.auth_token": "Токен авторизации (необязательно):", - "http_endpoint.auth_token.hint": "Отправляется как 'Authorization: Bearer '. Добавьте свой Authorization-заголовок, чтобы переопределить.", - "http_endpoint.auth_token.edit_hint": "Оставьте пустым, чтобы сохранить текущий токен", - "http_endpoint.auth_token.reveal": "Показать / скрыть токен", - "http_endpoint.auth.set": "Авторизация", - "http_endpoint.timeout": "Таймаут (с):", - "http_endpoint.timeout.hint": "Максимальное время ожидания одного запроса (в секундах).", - "http_endpoint.test": "Тестовый запрос", - "http_endpoint.test.pending": "Проверка…", - "http_endpoint.test.success": "Успех", - "http_endpoint.test.failed": "Ошибка", - "http_endpoint.test.body.json": "JSON-тело", - "http_endpoint.test.body.text": "Тело ответа", - "http_endpoint.headers": "Заголовки:", - "http_endpoint.headers.hint": "Дополнительные заголовки запроса (например, X-API-Key, Accept).", - "http_endpoint.headers.add": "Добавить заголовок", - "http_endpoint.headers.empty": "Нет дополнительных заголовков — будут отправлены значения по умолчанию.", - "http_endpoint.headers.name_placeholder": "Имя заголовка", - "http_endpoint.headers.value_placeholder": "Значение", - "http_endpoint.headers.count": "{n} заголовков", - "http_endpoint.description": "Описание (необязательно):", - "http_endpoint.created": "HTTP-эндпоинт создан", - "http_endpoint.updated": "HTTP-эндпоинт обновлён", - "http_endpoint.deleted": "HTTP-эндпоинт удалён", - "http_endpoint.delete.confirm": "Удалить этот HTTP-эндпоинт? Источники-значений, ссылающиеся на него, потребуется перенастроить.", - "http_endpoint.error.name_required": "Имя обязательно", - "http_endpoint.error.url_required": "URL обязателен", - "http_endpoint.error.timeout_invalid": "Таймаут должен быть положительным числом", - "http_endpoint.error.load": "Не удалось загрузить HTTP-эндпоинт", - "section.empty.http_endpoints": "Пока нет HTTP-эндпоинтов. Нажмите +, чтобы добавить.", - "device.icon.entity.http_endpoint": "HTTP-эндпоинт", - "value_source.type.http": "HTTP-опрос", - "value_source.type.http.desc": "Периодически опрашивает HTTP-эндпоинт и сопоставляет извлечённое значение с диапазоном 0–1.", - "value_source.http.endpoint": "HTTP-эндпоинт:", - "value_source.http.endpoint.hint": "Выберите сохранённый эндпоинт во вкладке HTTP-интеграций.", - "value_source.http.json_path": "JSON-путь:", - "value_source.http.json_path.hint": "Пусто = использовать необработанное тело ответа. Используйте путь с точками и индексами, например MediaContainer.Metadata[0].title", - "value_source.http.interval": "Интервал (с):", - "value_source.http.interval.hint": "Период опроса в секундах. Несколько источников могут использовать один эндпоинт с разными интервалами.", - "value_source.http.min_value": "Мин. значение:", - "value_source.http.min_value.hint": "Извлечённое значение, которое отображается в выход 0.0 (для нормализации).", - "value_source.http.max_value": "Макс. значение:", - "value_source.http.max_value.hint": "Извлечённое значение, которое отображается в выход 1.0 (для нормализации).", - "value_source.http.modulator.summary": "Сопоставление для модулятора (необязательно)", - "value_source.http.modulator.hint": "Используется только когда этот источник управляет яркостью или цветом. Правила автоматизации читают извлечённое значение в исходном виде и игнорируют эти настройки.", - "value_source.http.endpoint_required": "Требуется HTTP-эндпоинт", - "value_source.http.interval_invalid": "Интервал должен быть не меньше 1 секунды", - "value_source.type.template": "Шаблон Jinja", - "value_source.type.template.desc": "Объедините привязанные входы в изолированном выражении Jinja для вычисления значения 0-1.", - "value_source.template.expression": "Выражение:", - "value_source.template.expression.hint": "Изолированное выражение Jinja, возвращающее число. Привязанные входы доступны по имени; используйте raw[name] для ненормализованного значения.", - "value_source.template.expression.placeholder": "clamp((temp - 18) / 10, 0, 1)", - "value_source.template.inputs": "Входы:", - "value_source.template.inputs.hint": "Привяжите числовые источники значений к именам переменных, используемым в выражении.", - "value_source.template.inputs.empty": "Пока нет входов. Нажмите +, чтобы привязать источник значений.", - "value_source.template.add_input": "+ Добавить вход", - "value_source.template.input_name": "имя переменной", - "value_source.template.input_count": "входов", - "value_source.template.input_count_one": "вход", - "value_source.template.default_value": "Значение по умолчанию:", - "value_source.template.default_value.hint": "Значение на выходе, когда выражение нельзя вычислить (например, отсутствует вход).", - "value_source.template.eval_interval": "Интервал вычисления (с):", - "value_source.template.eval_interval.hint": "Как часто пересчитывать выражение. 0 = при каждом опросе (так быстро, как обновляются входы).", - "value_source.template.valid": "Выражение корректно", - "value_source.template.hints.title": "Справка по выражению", - "value_source.template.hints.inputs_title": "Привязанные входы", - "value_source.template.hints.no_inputs": "Входы ещё не привязаны", - "value_source.template.hints.globals_title": "Глобальные функции", - "value_source.template.hints.globals": "min(a, b), max(a, b), abs(x), round(x), clamp(x, lo, hi)", - "value_source.template.hints.raw_title": "Исходные значения", - "value_source.template.hints.raw": "raw[name] даёт ненормализованное значение входа, если оно есть.", - "value_source.template.hints.examples_title": "Примеры", - "value_source.template.hints.time": "Совет: для логики времени суток привяжите источник «Адаптивный (время)» или «Дневной цикл» как вход.", - "value_source.template.error.invalid_expr": "Некорректное выражение", - "value_source.template.error.cycle": "Это выражение создало бы циклическую зависимость", - "value_source.template.error.missing_input": "Каждому входу нужно имя переменной", - "value_source.template.error.invalid_name": "Некорректное имя переменной", - "value_source.template.error.reserved_name": "Зарезервированное имя нельзя использовать как вход", - "value_source.template.error.duplicate_name": "Повторяющееся имя входа", - "value_source.template.error.unbound": "Выражение ссылается на непривязанную переменную", - "automations.rule.http_poll": "HTTP-опрос", - "automations.rule.http_poll.desc": "Срабатывает, когда последнее значение HTTP-источника соответствует условию.", - "automations.rule.http_poll.hint": "Сравнивает последнее извлечённое значение с вашим вводом. Что именно извлекается (тело или JSON-путь), задаётся в источнике-значении.", - "automations.rule.http_poll.value_source": "HTTP источник-значения", - "automations.rule.http_poll.operator": "Оператор", - "automations.rule.http_poll.value": "Значение", - "automations.rule.http_poll.value.placeholder": "playing", - "automations.rule.http_poll.no_source": "(нет источника)", - "automations.rule.http_poll.raw_body": "Тело ответа", - "automations.rule.http_poll.operator.equals": "Равно", - "automations.rule.http_poll.operator.equals.desc": "Точное совпадение строки.", - "automations.rule.http_poll.operator.not_equals": "Не равно", - "automations.rule.http_poll.operator.not_equals.desc": "Срабатывает, когда значение отличается.", - "automations.rule.http_poll.operator.contains": "Содержит", - "automations.rule.http_poll.operator.contains.desc": "Поиск подстроки.", - "automations.rule.http_poll.operator.regex": "Regex", - "automations.rule.http_poll.operator.regex.desc": "Регулярное выражение в стиле JavaScript.", - "automations.rule.http_poll.operator.gt": "Больше", - "automations.rule.http_poll.operator.gt.desc": "Числовое сравнение (>) — нужно числовое значение.", - "automations.rule.http_poll.operator.lt": "Меньше", - "automations.rule.http_poll.operator.lt.desc": "Числовое сравнение (<) — нужно числовое значение.", - "automations.rule.http_poll.operator.exists": "Существует", - "automations.rule.http_poll.operator.exists.desc": "Срабатывает, когда значение успешно извлечено (само значение игнорируется).", - "autocal.modal.title": "Авто-калибровка полосы", - "autocal.trigger.label": "Авто-калибровка", - "autocal.trigger.hint": "Автоматически определить позиции светодиодов путём обхода полосы", - "autocal.device.title": "Выбор устройства", - "autocal.device.desc": "Выберите устройство WLED, управляющее этой LED-полосой. Во время калибровки полоса ненадолго загорится.", - "autocal.device.label": "Устройство", - "autocal.error.no_device": "Пожалуйста, выберите устройство для продолжения.", - "autocal.corner.title": "Начальный угол", - "autocal.corner.desc": "В каком углу находится светодиод №0 (самый первый светодиод полосы)?", - "autocal.corner.led_index": "Позиция LED 0", - "autocal.direction.title": "Направление полосы — шаг {step}", - "autocal.direction.desc": "В каком направлении идёт полоса от начального угла?", - "autocal.corners.title": "Отметьте углы — осталось {remaining}", - "autocal.corners.desc": "Переместитесь к следующему углу и нажмите «Отметить». Угол: {corner}", - "autocal.corners.desc_complete": "Все 4 угла отмечены! Проверьте и продолжите.", - "autocal.corners.index_label": "Индекс LED", - "autocal.preview.title": "Предпросмотр и сохранение", - "autocal.preview.desc": "Проверьте обнаруженную раскладку и сохраните в источник полосы.", - "autocal.preview.start": "Начальный угол", - "autocal.preview.top": "Верхних LED", - "autocal.preview.right": "Правых LED", - "autocal.preview.bottom": "Нижних LED", - "autocal.preview.left": "Левых LED", - "autocal.preview.total": "Всего LED", - "autocal.position.top_left": "Верхний левый", - "autocal.position.top_right": "Верхний правый", - "autocal.position.bottom_left": "Нижний левый", - "autocal.position.bottom_right": "Нижний правый", - "autocal.btn.cancel": "Отмена", - "autocal.btn.next": "Далее", - "autocal.btn.back": "Назад", - "autocal.btn.step_back": "Шаг назад", - "autocal.btn.step_fwd": "Шаг вперёд", - "autocal.btn.mark_corner": "Отметить угол", - "autocal.btn.solve": "Вычислить", - "autocal.btn.save": "Сохранить", - "autocal.error.session_start_failed": "Не удалось начать сеанс калибровки.", - "autocal.error.session_stop_failed": "Не удалось завершить сеанс калибровки.", - "autocal.error.position_failed": "Не удалось переместиться к позиции LED.", - "autocal.error.solve_failed": "Не удалось вычислить калибровку.", - "autocal.error.save_failed": "Не удалось сохранить калибровку.", - "autocal.error.css_required": "Авто-калибровка требует источника цветовой полосы (не только устройства).", - "autocal.saved": "Калибровка успешно сохранена.", - "wizard.modal.title": "Мастер настройки", - "wizard.rerun": "Запустить мастер настройки заново", - "wizard.skip": "Пропустить", - "wizard.start": "Начать", - "wizard.step.welcome": "Добро пожаловать", - "wizard.step.device": "Устройство", - "wizard.step.display": "Экран", - "wizard.step.scaffold": "Настройка", - "wizard.step.calibrate": "Калибровка", - "wizard.step.start": "Запуск", - "wizard.step.done": "Готово", - "wizard.welcome.title": "Добро пожаловать в LED Grab", - "wizard.welcome.desc": "Настроим вашу LED-ленту за несколько шагов.", - "wizard.welcome.item1": "Подключите контроллер LED", - "wizard.welcome.item2": "Выберите экран для захвата", - "wizard.welcome.item3": "Откалибруйте расположение ленты", - "wizard.welcome.item4": "Запустите подсветку", - "wizard.device.title": "Найдите устройство", - "wizard.device.desc": "Выполните сканирование сети или добавьте устройство вручную.", - "wizard.device.scanning": "Сканирование сети…", - "wizard.device.discovered": "Найдено в сети", - "wizard.device.none_found": "Устройства не найдены. Попробуйте добавить вручную.", - "wizard.device.rescan": "Повторить", - "wizard.device.existing": "Существующие устройства", - "wizard.device.manual.title": "Добавить вручную", - "wizard.device.manual.name": "Имя устройства", - "wizard.device.manual.name_placeholder": "Моя LED-лента", - "wizard.device.manual.url": "Адрес устройства", - "wizard.device.manual.led_count": "Количество светодиодов", - "wizard.device.manual.add": "Добавить устройство", - "wizard.display.title": "Выберите экран", - "wizard.display.desc": "Укажите монитор для захвата подсветки.", - "wizard.display.loading": "Загрузка дисплеев…", - "wizard.display.no_displays": "Дисплеи не найдены. Введите индекс вручную.", - "wizard.display.manual_index": "Индекс дисплея", - "wizard.display.primary": "Основной", - "wizard.display.index_prefix": "Дисплей", - "wizard.display.confirm": "Использовать этот экран", - "wizard.scaffold.title": "Создание конфигурации", - "wizard.scaffold.desc": "Создаём цепочку захвата: экран → цветовая лента → LED-выход.", - "wizard.scaffold.building": "Создание объектов…", - "wizard.scaffold.done": "Конфигурация создана! Готово к калибровке.", - "wizard.calibrate.title": "Калибровка ленты", - "wizard.calibrate.desc": "Укажите, где начинается лента и как она проходит вокруг экрана.", - "wizard.calibrate.skip": "Пропустить калибровку", - "wizard.start.title": "Запуск вывода", - "wizard.start.starting": "Запуск LED-вывода…", - "wizard.start.done": "LED-вывод работает!", - "wizard.start.failed": "Не удалось запустить. Запустите вручную на вкладке «Цели».", - "wizard.done.title": "Готово!", - "wizard.done.desc": "Ваша подсветка активна. Наслаждайтесь!", - "wizard.done.device": "Устройство", - "wizard.done.display": "Экран", - "wizard.done.finish": "Завершить", - "wizard.error.no_device": "Сначала выберите или добавьте устройство.", - "wizard.error.device_create_failed": "Не удалось создать устройство.", - "wizard.error.device_name_required": "Введите имя устройства.", - "wizard.error.device_url_required": "Введите адрес устройства.", - "wizard.error.scaffold_failed": "Ошибка настройки. Попробуйте ещё раз.", - "wizard.error.start_failed": "Не удалось запустить LED-вывод." + "accent.color.updated": "Цвет акцента обновлён", + "accent.custom": "Свой", + "accent.reset": "Сброс", + "accent.title": "Цвет акцента", + "activity_log.category.auth": "Авторизация", + "activity_log.category.capture": "Захват", + "activity_log.category.device": "Устройство", + "activity_log.category.entity": "Сущность", + "activity_log.category.system": "Система", + "activity_log.col.actor": "Субъект", + "activity_log.col.category": "Категория", + "activity_log.col.message": "Сообщение", + "activity_log.col.severity": "Уровень", + "activity_log.col.time": "Время", + "activity_log.detail.action": "Действие", + "activity_log.detail.actor": "Субъект", + "activity_log.detail.entity": "Сущность", + "activity_log.detail.id": "ID", + "activity_log.detail.metadata": "Метаданные", + "activity_log.detail.timestamp": "Метка времени", + "activity_log.detail.title": "Детали записи", + "activity_log.disabled": "Запись активности отключена.", + "activity_log.empty": "Нет записей, соответствующих текущим фильтрам.", + "activity_log.empty_no_filters": "Действия ещё не зафиксированы.", + "activity_log.error": "Не удалось загрузить журнал.", + "activity_log.export": "Экспорт", + "activity_log.export.csv": "Экспорт CSV", + "activity_log.export.downloading": "Загрузка…", + "activity_log.export.error": "Ошибка экспорта.", + "activity_log.export.json": "Экспорт JSON", + "activity_log.filter.actor": "Субъект", + "activity_log.filter.actor.placeholder": "system, имя API-ключа…", + "activity_log.filter.category": "Категория", + "activity_log.filter.clear": "Сбросить фильтры", + "activity_log.filter.entity_type": "Тип сущности", + "activity_log.filter.entity_type.placeholder": "output_target, device…", + "activity_log.filter.search": "Поиск сообщений…", + "activity_log.filter.severity": "Уровень", + "activity_log.filter.since": "С", + "activity_log.filter.title": "Фильтры", + "activity_log.filter.until": "По", + "activity_log.live": "Live", + "activity_log.load_more": "Загрузить ещё", + "activity_log.loading": "Загрузка журнала…", + "activity_log.n_entries": "Записей: {n}", + "activity_log.new_entries": "+{n} новых", + "activity_log.preset.auth": "Авторизация", + "activity_log.preset.devices": "Устройства", + "activity_log.preset.errors": "Ошибки", + "activity_log.preset.today": "Сегодня", + "activity_log.severity.error": "Ошибка", + "activity_log.severity.info": "Информация", + "activity_log.severity.warning": "Предупреждение", + "activity_log.subtitle": "Журнал действий LedGrab", + "activity_log.title": "Активность", + "activity_log.msg.auth.rejected": "Ошибка аутентификации: {reason} (клиент: {client})", + "activity_log.msg.auth.ws_connected": "WebSocket-сессия установлена: '{actor}'", + "activity_log.msg.activity_log.cleared": "Журнал активности очищен пользователем '{actor}'", + "activity_log.msg.calibration.started": "Калибровка запущена", + "activity_log.msg.calibration.stopped": "Калибровка остановлена", + "activity_log.msg.calibration.cancelled": "Калибровка отменена", + "activity_log.msg.playlist.started": "Плейлист '{name}' запущен", + "activity_log.msg.playlist.stopped": "Плейлист '{name}' остановлен", + "activity_log.msg.scene.activated": "Сцена '{name}' активирована", + "activity_log.msg.device.adb_connected": "ADB подключён к {address}", + "activity_log.msg.device.adb_disconnected": "ADB отключён от {address}", + "activity_log.msg.device.discovered": "Устройство обнаружено по адресу {address}", + "activity_log.msg.device.lost": "Устройство потеряно по адресу {address}", + "activity_log.msg.device.online": "Устройство '{name}' в сети", + "activity_log.msg.device.offline": "Устройство '{name}' недоступно", + "activity_log.msg.update.applied": "Обновление применено", + "activity_log.msg.update.dismissed": "Обновление отклонено", + "activity_log.msg.settings.changed": "Параметр '{key}' изменён", + "activity_log.msg.audit_log.disabled": "Запись активности отключена", + "activity_log.msg.automation.activated": "Автоматизация '{name}' активирована", + "activity_log.msg.automation.deactivated": "Автоматизация '{name}' деактивирована", + "activity_log.msg.server.shutting_down": "Сервер выключается", + "activity_log.msg.server.restarting": "Запрошен перезапуск сервера", + "activity_log.msg.server.shutdown_requested": "Запрошено выключение сервера", + "activity_log.msg.backup.created": "Резервная копия создана", + "activity_log.msg.backup.restored": "Резервная копия восстановлена", + "activity_log.msg.backup.deleted": "Резервная копия удалена", + "activity_log.msg.capture.started": "Захват запущен для '{name}'", + "activity_log.msg.capture.stopped": "Захват остановлен для '{name}'", + "activity_log.msg.entity.created": "{type} '{name}' создан", + "activity_log.msg.entity.updated": "{type} '{name}' обновлён", + "activity_log.msg.entity.deleted": "{type} '{name}' удалён", + "activity_log.entity_type.output_target": "Цель вывода", + "activity_log.entity_type.device": "Устройство", + "activity_log.entity_type.picture_source": "Источник изображения", + "activity_log.entity_type.color_strip_source": "Источник цветовой полосы", + "activity_log.entity_type.audio_source": "Аудиоисточник", + "activity_log.entity_type.automation": "Автоматизация", + "activity_log.entity_type.scene_preset": "Пресет сцены", + "activity_log.entity_type.scene_playlist": "Плейлист сцен", + "activity_log.entity_type.sync_clock": "Синх-часы", + "activity_log.entity_type.template": "Шаблон", + "activity_log.entity_type.gradient": "Градиент", + "activity_log.entity_type.cspt": "Шаблон обработки", + "activity_log.entity_type.audio_template": "Аудиошаблон", + "activity_log.entity_type.audio_processing_template": "Шаблон аудиообработки", + "activity_log.entity_type.audio_processing_profile": "Профиль аудиообработки", + "activity_log.entity_type.http_endpoint": "HTTP-эндпоинт", + "activity_log.entity_type.mqtt_source": "MQTT-источник", + "activity_log.entity_type.home_assistant_source": "Источник Home Assistant", + "activity_log.entity_type.weather_source": "Источник погоды", + "activity_log.entity_type.game_integration": "Игровая интеграция", + "activity_log.entity_type.value_source": "Источник значений", + "activity_log.entity_type.asset": "Ресурс", + "api.error.network": "Ошибка сети — проверьте подключение", + "api.error.timeout": "Превышено время ожидания — попробуйте снова", + "api_key.login": "Войти", + "app.api_docs": "Документация API", + "app.connection_lost": "Сервер недоступен", + "app.connection_retrying": "Попытка переподключения…", + "app.server_restarting": "Сервер перезапускается…", + "app.server_restarting_sub": "Пожалуйста, подождите, сервер скоро вернётся.", + "app.title": "LED Grab", + "app.version": "Версия:", + "appearance.bg.applied": "Фоновый эффект применён", + "appearance.bg.aurora": "Северное сияние", + "appearance.bg.grid": "Точечная сетка", + "appearance.bg.hint": "Добавьте фоновый слой за интерфейсом.", + "appearance.bg.label": "Фоновые эффекты", + "appearance.bg.mesh": "Градиент", + "appearance.bg.noise": "Шумовое поле", + "appearance.bg.none": "Нет", + "appearance.bg.plasma": "Плазма", + "appearance.bg.rain": "Цифровой дождь", + "appearance.bg.scanlines": "Развёртка", + "appearance.bg.stars": "Звёздное поле", + "appearance.bg.warp": "Тоннель", + "appearance.preset.applied": "Стиль применён", + "appearance.preset.arctic": "Арктика", + "appearance.preset.copper": "Медь", + "appearance.preset.default": "Стандарт", + "appearance.preset.ember": "Угли", + "appearance.preset.midnight": "Полночь", + "appearance.preset.monolith": "Монолит", + "appearance.preset.neon": "Неон", + "appearance.preset.ocean": "Океан", + "appearance.preset.sakura": "Сакура", + "appearance.preset.terminal": "Терминал", + "appearance.preset.vapor": "Вейпорвейв", + "appearance.style.hint": "Выберите визуальную тему — шрифт и цветовая палитра применяются вместе.", + "appearance.style.label": "Стили оформления", + "aria.cancel": "Отмена", + "aria.close": "Закрыть", + "aria.hint": "Показать подсказку", + "aria.next": "Вперёд", + "aria.previous": "Назад", + "aria.save": "Сохранить", + "asset.confirm_delete": "Удалить этот ресурс?", + "asset.deleted": "Ресурс удалён", + "asset.description": "Описание:", + "asset.description.hint": "Необязательное описание ресурса.", + "asset.download": "Скачать", + "asset.drop_or_browse": "Перетащите файл сюда или нажмите для выбора", + "asset.edit": "Редактировать ресурс", + "asset.error.delete_failed": "Не удалось удалить ресурс", + "asset.error.download_failed": "Не удалось скачать ресурс", + "asset.error.name_required": "Название обязательно", + "asset.error.no_file": "Выберите файл для загрузки", + "asset.error.play_failed": "Не удалось воспроизвести звук", + "asset.file": "Файл:", + "asset.file.hint": "Выберите файл для загрузки (звук, изображение, видео или другое).", + "asset.group.title": "Ресурсы", + "asset.name": "Название:", + "asset.name.hint": "Отображаемое название ресурса.", + "asset.play": "Воспроизвести", + "asset.prebuilt": "Встроенный", + "asset.prebuilt_none_to_restore": "Все встроенные ресурсы уже доступны", + "asset.prebuilt_restored": "Восстановлено встроенных ресурсов: {count}", + "asset.restore_prebuilt": "Восстановить встроенные звуки", + "asset.type.image": "Изображение", + "asset.type.other": "Другое", + "asset.type.sound": "Звук", + "asset.type.video": "Видео", + "asset.updated": "Ресурс обновлён", + "asset.upload": "Загрузить ресурс", + "asset.uploaded": "Ресурс загружен", + "audio_processing.add": "Добавить шаблон обработки звука", + "audio_processing.created": "Шаблон обработки звука создан", + "audio_processing.delete.confirm": "Вы уверены, что хотите удалить этот шаблон обработки звука?", + "audio_processing.deleted": "Шаблон обработки звука удалён", + "audio_processing.description.hint": "Опишите назначение этого шаблона", + "audio_processing.description_label": "Описание (необязательно):", + "audio_processing.description_placeholder": "Опишите этот шаблон...", + "audio_processing.edit": "Редактировать шаблон обработки звука", + "audio_processing.error.clone_failed": "Не удалось клонировать шаблон обработки звука", + "audio_processing.error.delete": "Ошибка удаления шаблона обработки звука", + "audio_processing.error.load": "Ошибка загрузки шаблона обработки звука", + "audio_processing.error.required": "Пожалуйста, заполните все обязательные поля", + "audio_processing.error.save_failed": "Не удалось сохранить шаблон обработки звука", + "audio_processing.filter_count": "Количество фильтров", + "audio_processing.filters_label": "фильтров", + "audio_processing.name": "Название шаблона:", + "audio_processing.name.hint": "Описательное название для этого шаблона обработки звука", + "audio_processing.name_placeholder": "Мой шаблон обработки звука", + "audio_processing.title": "Шаблоны обработки звука", + "audio_processing.updated": "Шаблон обработки звука обновлён", + "audio_source.add": "Добавить аудиоисточник", + "audio_source.add.capture": "Добавить источник захвата", + "audio_source.add.processed": "Добавить обработанный источник", + "audio_source.audio_template": "Аудиошаблон:", + "audio_source.audio_template.hint": "Шаблон аудиозахвата определяет, какой движок и настройки использовать для этого устройства", + "audio_source.created": "Аудиоисточник создан", + "audio_source.delete.confirm": "Удалить этот аудиоисточник?", + "audio_source.deleted": "Аудиоисточник удалён", + "audio_source.description": "Описание (необязательно):", + "audio_source.description.hint": "Необязательные заметки об этом аудиоисточнике", + "audio_source.description.placeholder": "Опишите этот аудиоисточник...", + "audio_source.device": "Аудиоустройство:", + "audio_source.device.hint": "Источник аудиосигнала. Устройства обратной петли захватывают системный звук; устройства ввода — микрофон или линейный вход.", + "audio_source.edit": "Редактировать аудиоисточник", + "audio_source.edit.capture": "Редактировать источник захвата", + "audio_source.edit.processed": "Редактировать обработанный источник", + "audio_source.error.load": "Не удалось загрузить аудиоисточник", + "audio_source.error.name_required": "Введите название", + "audio_source.group.capture": "Захват звука", + "audio_source.group.processed": "Обработанные источники", + "audio_source.name": "Название:", + "audio_source.name.hint": "Описательное имя для этого аудиоисточника", + "audio_source.name.placeholder": "Системный звук", + "audio_source.parent": "Входной аудиоисточник:", + "audio_source.parent.hint": "Аудиоисточник для применения фильтров обработки", + "audio_source.processing_template": "Шаблон обработки:", + "audio_source.processing_template.hint": "Шаблон обработки аудио с фильтрами для применения к входному источнику", + "audio_source.refresh_devices": "Обновить устройства", + "audio_source.test": "Тест", + "audio_source.test.beat": "Бит", + "audio_source.test.connecting": "Подключение...", + "audio_source.test.error": "Ошибка теста аудио", + "audio_source.test.peak": "Пик", + "audio_source.test.rms": "RMS", + "audio_source.test.title": "Тест аудиоисточника", + "audio_source.title": "Аудиоисточники", + "audio_source.type": "Тип:", + "audio_source.type.capture": "Захват", + "audio_source.type.hint": "Захват оборачивает физическое аудиоустройство. Обработанный применяет фильтры обработки к другому источнику.", + "audio_source.type.processed": "Обработанный", + "audio_source.updated": "Аудиоисточник обновлён", + "audio_template.add": "Добавить аудиошаблон", + "audio_template.config": "Конфигурация", + "audio_template.config.show": "Показать конфигурацию", + "audio_template.created": "Аудиошаблон создан", + "audio_template.delete.confirm": "Удалить этот аудиошаблон?", + "audio_template.deleted": "Аудиошаблон удалён", + "audio_template.description.label": "Описание (необязательно):", + "audio_template.description.placeholder": "Опишите этот шаблон...", + "audio_template.edit": "Редактировать аудиошаблон", + "audio_template.engine": "Аудиодвижок:", + "audio_template.engine.hint": "Выберите движок аудиозахвата. WASAPI — только Windows с поддержкой loopback. Sounddevice — кроссплатформенный.", + "audio_template.engine.unavailable": "Недоступен", + "audio_template.engine.unavailable.hint": "Этот движок недоступен в вашей системе", + "audio_template.error.clone_failed": "Не удалось клонировать аудиошаблон", + "audio_template.error.delete": "Не удалось удалить аудиошаблон", + "audio_template.error.engines": "Не удалось загрузить аудиодвижки", + "audio_template.error.load": "Не удалось загрузить аудиошаблоны", + "audio_template.error.load_failed": "Не удалось загрузить аудиошаблон", + "audio_template.error.required": "Пожалуйста, заполните все обязательные поля", + "audio_template.error.save_failed": "Не удалось сохранить аудиошаблон", + "audio_template.name": "Название шаблона:", + "audio_template.name.placeholder": "Мой аудиошаблон", + "audio_template.test": "Тест", + "audio_template.test.device": "Аудиоустройство:", + "audio_template.test.device.hint": "Выберите устройство для захвата звука во время теста", + "audio_template.test.run": "Запуск", + "audio_template.test.title": "Тест аудиошаблона", + "audio_template.title": "Аудиошаблоны", + "audio_template.updated": "Аудиошаблон обновлён", + "auth.authenticated": "● Авторизован", + "auth.button.cancel": "Отмена", + "auth.button.login": "Войти", + "auth.error.invalid": "Неверный API ключ. Попробуйте ещё раз.", + "auth.error.required": "Пожалуйста, введите API ключ", + "auth.hint": "Ваш API ключ будет безопасно сохранен в локальном хранилище браузера.", + "auth.label": "API Ключ:", + "auth.login": "Войти", + "auth.logout": "Выйти", + "auth.logout.confirm": "Вы уверены, что хотите выйти?", + "auth.logout.success": "Выход выполнен успешно", + "auth.message": "Пожалуйста, введите ваш API ключ для аутентификации и доступа к LED Grab.", + "auth.placeholder": "Введите ваш API ключ...", + "auth.please_login": "Пожалуйста, войдите для просмотра", + "auth.prompt_enter": "Enter your API key:", + "auth.prompt_update": "Current API key is set. Enter new key to update or leave blank to remove:", + "auth.session_expired": "Ваша сессия истекла или API ключ недействителен. Пожалуйста, войдите снова.", + "auth.success": "Вход выполнен успешно!", + "auth.title": "Вход в LED Grab", + "auth.toggle_password": "Показать/скрыть пароль", + "autocal.btn.back": "Назад", + "autocal.btn.cancel": "Отмена", + "autocal.btn.mark_corner": "Отметить угол", + "autocal.btn.next": "Далее", + "autocal.btn.save": "Сохранить", + "autocal.btn.solve": "Вычислить", + "autocal.btn.step_back": "Шаг назад", + "autocal.btn.step_fwd": "Шаг вперёд", + "autocal.corner.desc": "В каком углу находится светодиод №0 (самый первый светодиод полосы)?", + "autocal.corner.led_index": "Позиция LED 0", + "autocal.corner.title": "Начальный угол", + "autocal.corners.desc": "Переместитесь к следующему углу и нажмите «Отметить». Угол: {corner}", + "autocal.corners.desc_complete": "Все 4 угла отмечены! Проверьте и продолжите.", + "autocal.corners.index_label": "Индекс LED", + "autocal.corners.title": "Отметьте углы — осталось {remaining}", + "autocal.device.desc": "Выберите устройство WLED, управляющее этой LED-полосой. Во время калибровки полоса ненадолго загорится.", + "autocal.device.label": "Устройство", + "autocal.device.title": "Выбор устройства", + "autocal.direction.desc": "В каком направлении идёт полоса от начального угла?", + "autocal.direction.title": "Направление полосы — шаг {step}", + "autocal.error.css_required": "Авто-калибровка требует источника цветовой полосы (не только устройства).", + "autocal.error.no_device": "Пожалуйста, выберите устройство для продолжения.", + "autocal.error.position_failed": "Не удалось переместиться к позиции LED.", + "autocal.error.save_failed": "Не удалось сохранить калибровку.", + "autocal.error.session_start_failed": "Не удалось начать сеанс калибровки.", + "autocal.error.session_stop_failed": "Не удалось завершить сеанс калибровки.", + "autocal.error.solve_failed": "Не удалось вычислить калибровку.", + "autocal.modal.title": "Авто-калибровка полосы", + "autocal.position.bottom_left": "Нижний левый", + "autocal.position.bottom_right": "Нижний правый", + "autocal.position.top_left": "Верхний левый", + "autocal.position.top_right": "Верхний правый", + "autocal.preview.bottom": "Нижних LED", + "autocal.preview.desc": "Проверьте обнаруженную раскладку и сохраните в источник полосы.", + "autocal.preview.left": "Левых LED", + "autocal.preview.right": "Правых LED", + "autocal.preview.start": "Начальный угол", + "autocal.preview.title": "Предпросмотр и сохранение", + "autocal.preview.top": "Верхних LED", + "autocal.preview.total": "Всего LED", + "autocal.saved": "Калибровка успешно сохранена.", + "autocal.trigger.hint": "Автоматически определить позиции светодиодов путём обхода полосы", + "autocal.trigger.label": "Авто-калибровка", + "automation.disabled": "Автоматизация выключена", + "automation.enabled": "Автоматизация включена", + "automations.action.disable": "Отключить", + "automations.add": "Добавить автоматизацию", + "automations.created": "Автоматизация создана", + "automations.deactivation_mode": "Деактивация:", + "automations.deactivation_mode.fallback_scene": "Резервная", + "automations.deactivation_mode.fallback_scene.desc": "Активировать резервную сцену", + "automations.deactivation_mode.hint": "Что происходит, когда условия перестают выполняться", + "automations.deactivation_mode.none": "Ничего", + "automations.deactivation_mode.none.desc": "Оставить текущее состояние", + "automations.deactivation_mode.revert": "Откатить", + "automations.deactivation_mode.revert.desc": "Вернуть предыдущее состояние", + "automations.deactivation_scene": "Резервная сцена:", + "automations.deactivation_scene.hint": "Сцена для активации при деактивации автоматизации", + "automations.delete.confirm": "Удалить автоматизацию \"{name}\"?", + "automations.deleted": "Автоматизация удалена", + "automations.edit": "Редактировать автоматизацию", + "automations.empty": "Автоматизации не настроены. Создайте автоматизацию для автоматической активации сцен.", + "automations.enabled": "Включена:", + "automations.enabled.hint": "Отключённые автоматизации не активируются даже при выполнении условий", + "automations.error.clone_failed": "Не удалось клонировать автоматизацию", + "automations.error.delete_failed": "Не удалось удалить автоматизацию", + "automations.error.load_failed": "Не удалось загрузить автоматизацию", + "automations.error.name_required": "Введите название", + "automations.error.save_failed": "Не удалось сохранить автоматизацию", + "automations.error.toggle_failed": "Не удалось переключить автоматизацию", + "automations.last_activated": "Последняя активация", + "automations.logic.all": "ВСЕ", + "automations.logic.and": " И ", + "automations.logic.any": "ЛЮБОЕ", + "automations.logic.or": " ИЛИ ", + "automations.name": "Название:", + "automations.name.hint": "Описательное имя для автоматизации", + "automations.name.placeholder": "Моя автоматизация", + "automations.rule.application": "Приложение", + "automations.rule.application.apps": "Приложения:", + "automations.rule.application.apps.hint": "Имена процессов, по одному на строку (например firefox.exe)", + "automations.rule.application.apps.hint_android": "Имена пакетов, по одному в строке (напр. com.netflix.mediaclient)", + "automations.rule.application.browse": "Обзор", + "automations.rule.application.desc": "Приложение запущено", + "automations.rule.application.match_type": "Тип соответствия:", + "automations.rule.application.match_type.fullscreen": "Полный экран", + "automations.rule.application.match_type.fullscreen.desc": "Любое полноэкранное", + "automations.rule.application.match_type.hint": "Как определять наличие приложения", + "automations.rule.application.match_type.running": "Запущено", + "automations.rule.application.match_type.running.desc": "Процесс активен", + "automations.rule.application.match_type.topmost": "На переднем плане", + "automations.rule.application.match_type.topmost.desc": "Окно в фокусе", + "automations.rule.application.match_type.topmost_fullscreen": "Передний план + ПЭ", + "automations.rule.application.match_type.topmost_fullscreen.desc": "В фокусе + полный экран", + "automations.rule.application.no_apps": "Приложения не найдены", + "automations.rule.application.no_processes": "Процессы не найдены", + "automations.rule.application.search": "Фильтр процессов...", + "automations.rule.application.search_apps": "Поиск приложений...", + "automations.rule.application.usage_access_required": "Требуется доступ к статистике использования. Откройте LedGrab на телевизоре и нажмите «Разрешить доступ к статистике использования».", + "automations.rule.display_state": "Состояние дисплея", + "automations.rule.display_state.desc": "Монитор вкл/выкл", + "automations.rule.display_state.off": "Выключен (спящий режим)", + "automations.rule.display_state.on": "Включён", + "automations.rule.display_state.state": "Состояние монитора:", + "automations.rule.http_poll": "HTTP-опрос", + "automations.rule.http_poll.desc": "Срабатывает, когда последнее значение HTTP-источника соответствует условию.", + "automations.rule.http_poll.hint": "Сравнивает последнее извлечённое значение с вашим вводом. Что именно извлекается (тело или JSON-путь), задаётся в источнике-значении.", + "automations.rule.http_poll.no_source": "(нет источника)", + "automations.rule.http_poll.operator": "Оператор", + "automations.rule.http_poll.operator.contains": "Содержит", + "automations.rule.http_poll.operator.contains.desc": "Поиск подстроки.", + "automations.rule.http_poll.operator.equals": "Равно", + "automations.rule.http_poll.operator.equals.desc": "Точное совпадение строки.", + "automations.rule.http_poll.operator.exists": "Существует", + "automations.rule.http_poll.operator.exists.desc": "Срабатывает, когда значение успешно извлечено (само значение игнорируется).", + "automations.rule.http_poll.operator.gt": "Больше", + "automations.rule.http_poll.operator.gt.desc": "Числовое сравнение (\u003e) — нужно числовое значение.", + "automations.rule.http_poll.operator.lt": "Меньше", + "automations.rule.http_poll.operator.lt.desc": "Числовое сравнение (\u003c) — нужно числовое значение.", + "automations.rule.http_poll.operator.not_equals": "Не равно", + "automations.rule.http_poll.operator.not_equals.desc": "Срабатывает, когда значение отличается.", + "automations.rule.http_poll.operator.regex": "Regex", + "automations.rule.http_poll.operator.regex.desc": "Регулярное выражение в стиле JavaScript.", + "automations.rule.http_poll.raw_body": "Тело ответа", + "automations.rule.http_poll.value": "Значение", + "automations.rule.http_poll.value.placeholder": "playing", + "automations.rule.http_poll.value_source": "HTTP источник-значения", + "automations.rule.mqtt": "MQTT", + "automations.rule.mqtt.desc": "MQTT сообщение", + "automations.rule.mqtt.hint": "Активировать при получении совпадающего значения по MQTT топику", + "automations.rule.mqtt.match_mode": "Режим сравнения:", + "automations.rule.mqtt.match_mode.contains": "Содержит", + "automations.rule.mqtt.match_mode.exact": "Точное совпадение", + "automations.rule.mqtt.match_mode.regex": "Регулярное выражение", + "automations.rule.mqtt.payload": "Значение:", + "automations.rule.mqtt.topic": "Топик:", + "automations.rule.startup": "Автозапуск", + "automations.rule.startup.desc": "При запуске сервера", + "automations.rule.startup.hint": "Активируется при запуске сервера и остаётся активной пока включена.", + "automations.rule.system_idle": "Бездействие системы", + "automations.rule.system_idle.desc": "Бездействие/активность", + "automations.rule.system_idle.idle_minutes": "Тайм-аут бездействия (минуты):", + "automations.rule.system_idle.mode": "Режим срабатывания:", + "automations.rule.system_idle.when_active": "При активности", + "automations.rule.system_idle.when_active.desc": "Срабатывает, пока пользователь активно работает с системой", + "automations.rule.system_idle.when_idle": "При бездействии", + "automations.rule.system_idle.when_idle.desc": "Срабатывает, когда пользователь не активен дольше тайм-аута", + "automations.rule.time_of_day": "Время суток", + "automations.rule.time_of_day.days": "Активные дни", + "automations.rule.time_of_day.days_hint": "Оставьте всё невыбранным для всех дней. Ночные окна относятся ко дню, когда они начинаются.", + "automations.rule.time_of_day.desc": "Диапазон времени", + "automations.rule.time_of_day.end_time": "Время окончания:", + "automations.rule.time_of_day.overnight_hint": "Для ночных диапазонов (например 22:00–06:00) укажите время начала позже времени окончания.", + "automations.rule.time_of_day.start_time": "Время начала:", + "automations.rule.time_of_day.timezone": "Часовой пояс", + "automations.rule.time_of_day.timezone.placeholder": "Локальное время сервера (напр. Europe/Berlin)", + "automations.rule.webhook": "Вебхук", + "automations.rule.webhook.copied": "Скопировано!", + "automations.rule.webhook.copy": "Скопировать", + "automations.rule.webhook.desc": "HTTP вызов", + "automations.rule.webhook.hint": "Активировать через HTTP-запрос от внешних сервисов (Home Assistant, IFTTT, curl и т.д.)", + "automations.rule.webhook.save_first": "Сначала сохраните автоматизацию для генерации URL вебхука", + "automations.rule.webhook.url": "URL вебхука:", + "automations.rule_logic": "Логика условий:", + "automations.rule_logic.and": "Все условия (И)", + "automations.rule_logic.and.desc": "Срабатывает только при всех", + "automations.rule_logic.hint": "Как объединяются несколько условий: ЛЮБОЕ (ИЛИ) или ВСЕ (И)", + "automations.rule_logic.or": "Любое условие (ИЛИ)", + "automations.rule_logic.or.desc": "Срабатывает при любом совпадении", + "automations.rules": "Условия:", + "automations.rules.add": "Добавить условие", + "automations.rules.empty": "Нет условий — автоматизация всегда активна когда включена", + "automations.rules.hint": "Правила, определяющие когда автоматизация активируется", + "automations.scene": "Сцена:", + "automations.scene.hint": "Пресет сцены для активации при выполнении условий", + "automations.scene.none_available": "Нет доступных сцен", + "automations.scene.none_selected": "Нет (без сцены)", + "automations.scene.search_placeholder": "Поиск сцен...", + "automations.section.action": "Действие", + "automations.section.deactivation": "Деактивация", + "automations.section.triggers": "Триггеры", + "automations.status.active": "Активна", + "automations.status.disabled": "Отключена", + "automations.status.inactive": "Неактивна", + "automations.title": "Автоматизации", + "automations.updated": "Автоматизация обновлена", + "bg.anim.toggle": "Анимированный фон", + "bindable.none": "Нет (статическое значение)", + "bindable.toggle": "Привязка к источнику значений", + "bulk.cancel": "Отмена", + "bulk.confirm_delete.few": "Удалить {count} элемента?", + "bulk.confirm_delete.many": "Удалить {count} элементов?", + "bulk.confirm_delete.one": "Удалить {count} элемент?", + "bulk.delete": "Удалить", + "bulk.deselect_all": "Снять выбор", + "bulk.disable": "Выключить", + "bulk.enable": "Включить", + "bulk.processing": "Обработка…", + "bulk.select": "Выбрать", + "bulk.select_all": "Выбрать все", + "bulk.selected_count.few": "{count} выбрано", + "bulk.selected_count.many": "{count} выбрано", + "bulk.selected_count.one": "{count} выбран", + "bulk.start": "Запустить", + "bulk.stop": "Остановить", + "calibration.advanced.border_width": "Глубина (пкс):", + "calibration.advanced.border_width.hint": "Сколько пикселей вглубь от края захватывать. Большие значения берут больше внутренней части экрана.", + "calibration.advanced.canvas_hint": "Перетаскивайте мониторы. Нажимайте на грани для выбора линий. Прокрутка — масштаб, перетаскивание пустого места — сдвиг.", + "calibration.advanced.edge": "Грань:", + "calibration.advanced.edge.hint": "С какой грани экрана снимать пиксели", + "calibration.advanced.led_count": "Светодиоды:", + "calibration.advanced.led_count.hint": "Количество светодиодов на этой линии", + "calibration.advanced.line_properties": "Свойства линии", + "calibration.advanced.lines_title": "Линии", + "calibration.advanced.no_lines_warning": "Добавьте хотя бы одну линию", + "calibration.advanced.picture_source": "Источник:", + "calibration.advanced.picture_source.hint": "Источник изображения (монитор), с которого эта линия снимает данные", + "calibration.advanced.reset_view": "Сбросить вид", + "calibration.advanced.reverse": "Реверс", + "calibration.advanced.span_end": "Конец:", + "calibration.advanced.span_end.hint": "Где заканчивается захват вдоль грани (0 = начало, 1 = конец). Вместе с «Начало» определяет активный участок.", + "calibration.advanced.span_start": "Начало:", + "calibration.advanced.span_start.hint": "Откуда начинается захват вдоль грани (0 = начало, 1 = конец). Позволяет покрыть только часть грани.", + "calibration.advanced.switch_to_simple": "Простой режим", + "calibration.advanced.title": "Расширенная калибровка", + "calibration.border_width": "Граница (px):", + "calibration.border_width.hint": "Сколько пикселей от края экрана выбирать для цвета LED (1-100)", + "calibration.button.cancel": "Отмена", + "calibration.button.save": "Сохранить", + "calibration.corner.bottom_left": "Задать начальную позицию: нижний левый угол", + "calibration.corner.bottom_right": "Задать начальную позицию: нижний правый угол", + "calibration.corner.top_left": "Задать начальную позицию: верхний левый угол", + "calibration.corner.top_right": "Задать начальную позицию: верхний правый угол", + "calibration.direction": "Направление:", + "calibration.direction.clockwise": "По Часовой Стрелке", + "calibration.direction.counterclockwise": "Против Часовой Стрелки", + "calibration.direction.toggle": "Переключить направление", + "calibration.edge.bottom": "Переключить тестовые светодиоды нижнего края", + "calibration.edge.left": "Переключить тестовые светодиоды левого края", + "calibration.edge.right": "Переключить тестовые светодиоды правого края", + "calibration.edge.top": "Переключить тестовые светодиоды верхнего края", + "calibration.edge_inputs.toggle": "Переключить поля ввода светодиодов по краям", + "calibration.error.css_load_failed": "Не удалось загрузить источник цветовой полосы", + "calibration.error.led_count_exceeded": "Калиброванных LED больше, чем общее количество LED", + "calibration.error.led_count_mismatch": "Общее количество LED должно совпадать с количеством LED устройства", + "calibration.error.load_failed": "Не удалось загрузить калибровку", + "calibration.error.save_failed": "Не удалось сохранить калибровку", + "calibration.error.test_toggle_failed": "Не удалось переключить тестовый край", + "calibration.failed": "Не удалось сохранить калибровку", + "calibration.leds.bottom": "Светодиодов Снизу:", + "calibration.leds.left": "Светодиодов Слева:", + "calibration.leds.right": "Светодиодов Справа:", + "calibration.leds.top": "Светодиодов Сверху:", + "calibration.mode.advanced": "Расширенный", + "calibration.mode.simple": "Простой", + "calibration.offset": "Смещение LED:", + "calibration.offset.hint": "Расстояние от физического LED 0 до стартового угла (по направлению ленты)", + "calibration.overlay_toggle": "Оверлей", + "calibration.position.bottom_left": "Нижний Левый", + "calibration.position.bottom_right": "Нижний Правый", + "calibration.position.top_left": "Верхний Левый", + "calibration.position.top_right": "Верхний Правый", + "calibration.roi": "Область захвата (%):", + "calibration.roi.height": "Высота (%)", + "calibration.roi.hint": "Брать пиксели только из этого прямоугольника экрана, чтобы панель задач, интерфейс игры или чёрные полосы не искажали цвета краёв. Весь экран = X/Y 0, Ширина/Высота 100.", + "calibration.roi.width": "Ширина (%)", + "calibration.roi.x": "X (%)", + "calibration.roi.y": "Y (%)", + "calibration.saved": "Калибровка сохранена", + "calibration.skip_end": "Пропуск LED (конец):", + "calibration.skip_end.hint": "Количество LED, которые будут выключены в конце ленты (0 = нет)", + "calibration.skip_start": "Пропуск LED (начало):", + "calibration.skip_start.hint": "Количество LED, которые будут выключены в начале ленты (0 = нет)", + "calibration.start_position": "Начальная Позиция:", + "calibration.switch_to_advanced": "Расширенный режим", + "calibration.tip.border_width": "Сколько пикселей от края экрана использовать для цветов LED", + "calibration.tip.direction": "Переключение направления ленты (по часовой / против часовой)", + "calibration.tip.led_count": "Укажите количество LED на каждой стороне", + "calibration.tip.offset": "Смещение LED — расстояние от LED 0 до стартового угла", + "calibration.tip.overlay": "Включите оверлей для отображения позиций и нумерации LED на мониторе", + "calibration.tip.skip_leds_end": "Пропуск LED в конце ленты — пропущенные LED остаются выключенными", + "calibration.tip.skip_leds_start": "Пропуск LED в начале ленты — пропущенные LED остаются выключенными", + "calibration.tip.span": "Перетащите зелёные полосы для настройки зоны покрытия", + "calibration.tip.start_corner": "Нажмите на угол для выбора стартовой позиции", + "calibration.tip.test": "Нажмите на край для теста LED", + "calibration.tip.toggle_inputs": "Нажмите на общее количество LED для скрытия боковых полей", + "calibration.title": "Калибровка Светодиодов", + "calibration.tutorial.start": "Начать обучение", + "card_mode.comfortable": "Просторно", + "card_mode.compact": "Компактно", + "card_mode.dense": "Плотно", + "card_mode.row": "Список", + "card_mode.tooltip": "Размер карточек", + "color_strip.add": "Добавить", + "color_strip.animation": "Анимация", + "color_strip.animation.speed": "Скорость:", + "color_strip.animation.speed.hint": "Множитель скорости анимации. 1.0 ≈ один цикл в секунду для дыхания; большие значения ускоряют анимацию.", + "color_strip.animation.type": "Эффект:", + "color_strip.animation.type.breathing": "Дыхание", + "color_strip.animation.type.breathing.desc": "Плавное угасание и нарастание яркости", + "color_strip.animation.type.candle": "Свеча", + "color_strip.animation.type.candle.desc": "Тёплое мерцание, как у свечи", + "color_strip.animation.type.gradient_shift": "Сдвиг градиента", + "color_strip.animation.type.gradient_shift.desc": "Сдвигает градиент вдоль ленты", + "color_strip.animation.type.hint": "Эффект анимации.", + "color_strip.animation.type.none": "Нет (без эффекта анимации)", + "color_strip.animation.type.none.desc": "Статичные цвета без анимации", + "color_strip.animation.type.pulse": "Пульс", + "color_strip.animation.type.pulse.desc": "Резкая вспышка яркости с быстрым затуханием", + "color_strip.animation.type.rainbow_fade": "Радужный перелив", + "color_strip.animation.type.rainbow_fade.desc": "Циклический переход по всему спектру оттенков", + "color_strip.animation.type.sparkle": "Искры", + "color_strip.animation.type.sparkle.desc": "Случайные светодиоды кратковременно вспыхивают", + "color_strip.animation.type.strobe": "Стробоскоп", + "color_strip.animation.type.strobe.desc": "Быстрое мигание вкл/выкл", + "color_strip.animation.type.wave": "Волна", + "color_strip.animation.type.wave.desc": "Синусоидальная волна яркости вдоль ленты", + "color_strip.api_input.endpoints": "Эндпоинты для отправки:", + "color_strip.api_input.endpoints.hint": "Используйте эти URL для отправки данных о цветах LED из вашего внешнего приложения. REST принимает JSON, WebSocket принимает как JSON, так и бинарные кадры.", + "color_strip.api_input.fallback_color": "Цвет по умолчанию:", + "color_strip.api_input.fallback_color.hint": "Цвет для отображения, когда данные не получены в течение периода ожидания. LED покажут этот цвет при запуске и после потери соединения.", + "color_strip.api_input.interpolation": "Интерполяция LED:", + "color_strip.api_input.interpolation.hint": "Как масштабировать входящие данные LED, когда их количество отличается от количества LED на устройстве. Линейная — плавное смешивание, Ближайший — чёткие границы, Нет — обрезка или дополнение нулями.", + "color_strip.api_input.interpolation.linear": "Линейная", + "color_strip.api_input.interpolation.linear.desc": "Плавное смешивание между LED", + "color_strip.api_input.interpolation.nearest": "Ближайший", + "color_strip.api_input.interpolation.nearest.desc": "Чёткие границы, без смешивания", + "color_strip.api_input.interpolation.none": "Нет", + "color_strip.api_input.interpolation.none.desc": "Обрезка или дополнение нулями", + "color_strip.api_input.save_first": "Сначала сохраните источник, чтобы увидеть URL эндпоинтов.", + "color_strip.api_input.timeout": "Тайм-аут (секунды):", + "color_strip.api_input.timeout.hint": "Время ожидания новых данных о цветах перед возвратом к цвету по умолчанию. Установите 0, чтобы не использовать тайм-аут.", + "color_strip.audio.beat_decay": "Затухание бита:", + "color_strip.audio.beat_decay.hint": "Скорость затухания пульса. Меньшие значения = более долгое затухание, большие = более резкая реакция.", + "color_strip.audio.color": "Базовый цвет:", + "color_strip.audio.color.hint": "Цвет низкого уровня для полосы VU-метра.", + "color_strip.audio.color_peak": "Пиковый цвет:", + "color_strip.audio.color_peak.hint": "Цвет высокого уровня в верхней части полосы VU-метра.", + "color_strip.audio.mirror": "Зеркало:", + "color_strip.audio.mirror.hint": "Зеркалирование спектра от центра к краям: басы в середине, высокие частоты по краям.", + "color_strip.audio.palette": "Палитра:", + "color_strip.audio.palette.hint": "Цветовая палитра для полос спектра или пульсации бита.", + "color_strip.audio.sensitivity": "Чувствительность:", + "color_strip.audio.sensitivity.hint": "Множитель усиления аудиосигнала. Более высокие значения делают LED чувствительнее к тихим звукам.", + "color_strip.audio.smoothing": "Сглаживание:", + "color_strip.audio.smoothing.hint": "Временное сглаживание между кадрами. Более высокие значения дают плавную, но медленнее реагирующую визуализацию.", + "color_strip.audio.source": "Аудиоисточник:", + "color_strip.audio.source.hint": "Аудиоисточник для визуализации. Может быть многоканальным (устройство) или моно (один канал). Создавайте и управляйте аудиоисточниками на вкладке Источники.", + "color_strip.audio.visualization": "Визуализация:", + "color_strip.audio.visualization.hint": "Способ отображения аудиоданных на LED.", + "color_strip.audio.viz.beat_pulse": "Пульс бита", + "color_strip.audio.viz.beat_pulse.desc": "Все LED пульсируют в такт", + "color_strip.audio.viz.energy_gradient": "Энергетический градиент", + "color_strip.audio.viz.energy_gradient.desc": "Интенсивность градиента следует за энергией звука", + "color_strip.audio.viz.pulse_on_beat": "Пульс на бит", + "color_strip.audio.viz.pulse_on_beat.desc": "LED пульсируют при каждом ударе", + "color_strip.audio.viz.spectrum": "Анализатор спектра", + "color_strip.audio.viz.spectrum.desc": "Частотные полосы по ленте", + "color_strip.audio.viz.spectrum_bands": "Полосы спектра", + "color_strip.audio.viz.spectrum_bands.desc": "Группы частот по ленте", + "color_strip.audio.viz.strobe_on_drop": "Стробоскоп на дропе", + "color_strip.audio.viz.strobe_on_drop.desc": "Вспышка стробоскопа на басовых дропах", + "color_strip.audio.viz.vu_meter": "VU-метр", + "color_strip.audio.viz.vu_meter.desc": "Уровень громкости заполняет ленту", + "color_strip.brightness": "Яркость:", + "color_strip.brightness.hint": "Множитель яркости (0=выкл, 1=без изменений, 2=двойная). Применяется после извлечения цвета.", + "color_strip.candlelight.color": "Базовый цвет:", + "color_strip.candlelight.color.hint": "Тёплый базовый цвет пламени свечи. По умолчанию — натуральный тёплый янтарь.", + "color_strip.candlelight.intensity": "Интенсивность мерцания:", + "color_strip.candlelight.intensity.hint": "Сила мерцания свечей. Низкие значения — мягкое свечение, высокие — свеча на ветру.", + "color_strip.candlelight.num_candles": "свечей", + "color_strip.candlelight.num_candles.hint": "Сколько независимых источников свечей вдоль ленты. Каждый мерцает по-своему.", + "color_strip.candlelight.num_candles_label": "Количество свечей:", + "color_strip.candlelight.speed": "Скорость мерцания:", + "color_strip.candlelight.speed.hint": "Скорость анимации мерцания. Большие значения — более быстрое, беспокойное пламя.", + "color_strip.clock": "Часы синхронизации:", + "color_strip.clock.hint": "Привязка к часам для синхронизации анимации между источниками. Скорость управляется на часах.", + "color_strip.color_corrections": "Цветокоррекция", + "color_strip.composite.add_layer": "Добавить слой", + "color_strip.composite.blend_mode": "Смешивание", + "color_strip.composite.blend_mode.add": "Сложение", + "color_strip.composite.blend_mode.add.desc": "Осветляет, складывая цвета", + "color_strip.composite.blend_mode.difference": "Разница", + "color_strip.composite.blend_mode.difference.desc": "Абсолютная разница цветов", + "color_strip.composite.blend_mode.exclusion": "Исключение", + "color_strip.composite.blend_mode.exclusion.desc": "Как разница, но мягче", + "color_strip.composite.blend_mode.hard_light": "Жёсткий свет", + "color_strip.composite.blend_mode.hard_light.desc": "Сильный контраст, яркие цвета", + "color_strip.composite.blend_mode.multiply": "Умножение", + "color_strip.composite.blend_mode.multiply.desc": "Затемняет, умножая цвета", + "color_strip.composite.blend_mode.normal": "Обычное", + "color_strip.composite.blend_mode.normal.desc": "Стандартное альфа-смешивание", + "color_strip.composite.blend_mode.overlay": "Наложение", + "color_strip.composite.blend_mode.overlay.desc": "Умножение тёмных, осветление светлых", + "color_strip.composite.blend_mode.override": "Замена", + "color_strip.composite.blend_mode.override.desc": "Чёрный = прозрачный, яркий = непрозрачный", + "color_strip.composite.blend_mode.screen": "Экран", + "color_strip.composite.blend_mode.screen.desc": "Осветляет, обратное умножение", + "color_strip.composite.blend_mode.soft_light": "Мягкий свет", + "color_strip.composite.blend_mode.soft_light.desc": "Мягкая коррекция контраста", + "color_strip.composite.brightness": "Яркость", + "color_strip.composite.brightness.none": "Нет (полная яркость)", + "color_strip.composite.enabled": "Включён", + "color_strip.composite.error.min_layers": "Необходим хотя бы 1 слой", + "color_strip.composite.error.no_source": "Для каждого слоя должен быть выбран источник", + "color_strip.composite.layers": "Слои:", + "color_strip.composite.layers.hint": "Наложение нескольких источников. Первый слой — нижний, последний — верхний. Каждый слой может иметь свой режим смешивания и прозрачность.", + "color_strip.composite.layers_count": "слоёв", + "color_strip.composite.opacity": "Непрозрачность", + "color_strip.composite.processing": "Обработка", + "color_strip.composite.range": "Диапазон LED", + "color_strip.composite.range_end": "Конец", + "color_strip.composite.range_start": "Начало", + "color_strip.composite.reverse": "Реверс", + "color_strip.composite.source": "Источник", + "color_strip.created": "Источник цветовой полосы создан", + "color_strip.daylight.latitude": "Широта:", + "color_strip.daylight.latitude.hint": "Географическая широта (-90 до 90). Влияет на время восхода/заката в режиме реального времени.", + "color_strip.daylight.real_time": "Реальное время", + "color_strip.daylight.speed": "Скорость:", + "color_strip.daylight.speed.hint": "Множитель скорости цикла. 1.0 = полный цикл день/ночь за ~4 минуты.", + "color_strip.daylight.use_real_time": "Реальное время:", + "color_strip.daylight.use_real_time.hint": "Если включено, цвет LED соответствует реальному времени суток. Настройка скорости игнорируется.", + "color_strip.delete.confirm": "Удалить этот источник цветовой полосы?", + "color_strip.delete.referenced": "Невозможно удалить: источник используется в цели", + "color_strip.deleted": "Источник цветовой полосы удалён", + "color_strip.edit": "Редактировать", + "color_strip.effect.aurora": "Аврора", + "color_strip.effect.aurora.desc": "Наложенные шумовые полосы, дрейфующие и смешивающиеся — в стиле северного сияния", + "color_strip.effect.color": "Цвет метеора:", + "color_strip.effect.color.hint": "Цвет головной точки метеора.", + "color_strip.effect.fire": "Огонь", + "color_strip.effect.fire.desc": "Клеточный автомат, имитирующий поднимающееся пламя с диффузией тепла", + "color_strip.effect.intensity": "Интенсивность:", + "color_strip.effect.intensity.hint": "Интенсивность эффекта — частота искр (огонь), затухание хвоста (метеор) или диапазон яркости (аврора).", + "color_strip.effect.meteor": "Метеор", + "color_strip.effect.meteor.desc": "Яркая точка движется по ленте с экспоненциально затухающим хвостом", + "color_strip.effect.mirror": "Отражение:", + "color_strip.effect.mirror.hint": "Режим отскока — метеор меняет направление у краёв ленты вместо переноса.", + "color_strip.effect.noise": "Шум", + "color_strip.effect.noise.desc": "Прокручиваемый фрактальный шум, отображённый на палитру", + "color_strip.effect.palette": "Палитра:", + "color_strip.effect.palette.hint": "Цветовая палитра для отображения значений эффекта в RGB-цвета.", + "color_strip.effect.plasma": "Плазма", + "color_strip.effect.plasma.desc": "Наложение синусоидальных волн с палитрой — классический демо-эффект", + "color_strip.effect.scale": "Масштаб:", + "color_strip.effect.scale.hint": "Пространственный масштаб — частота волн (плазма), уровень масштабирования (шум) или ширина полос (аврора).", + "color_strip.effect.speed": "Скорость:", + "color_strip.effect.speed.hint": "Множитель скорости анимации эффекта (0.1 = очень медленно, 10.0 = очень быстро).", + "color_strip.effect.type": "Тип эффекта:", + "color_strip.effect.type.hint": "Выберите процедурный алгоритм.", + "color_strip.error.clone_failed": "Не удалось клонировать источник цветовой полосы", + "color_strip.error.delete_failed": "Не удалось удалить источник цветовой полосы", + "color_strip.error.editor_open_failed": "Не удалось открыть редактор цветовой полосы", + "color_strip.error.name_required": "Введите название", + "color_strip.fps": "Целевой FPS:", + "color_strip.fps.hint": "Целевая частота кадров для обновления цветов светодиодов (10-90)", + "color_strip.frame_interpolation": "Интерполяция кадров:", + "color_strip.frame_interpolation.hint": "Смешивает последовательные захваченные кадры для вывода на полной целевой частоте кадров, даже если скорость захвата ниже. Уменьшает заметные ступеньки при плавных переходах.", + "color_strip.game_event.error.no_integration": "Выберите игровую интеграцию.", + "color_strip.game_event.event_mappings": "Привязка событий:", + "color_strip.game_event.event_mappings.hint": "Переопределите или добавьте привязки событий к эффектам для этого источника.", + "color_strip.game_event.idle_color": "Цвет простоя:", + "color_strip.game_event.idle_color.hint": "Цвет LED, когда нет активных игровых событий.", + "color_strip.game_event.integration": "Игровая интеграция:", + "color_strip.game_event.integration.hint": "Выберите игровую интеграцию, от которой поступают события.", + "color_strip.gamma": "Гамма:", + "color_strip.gamma.hint": "Гамма-коррекция (1=без коррекции, \u003c1=ярче средние тона, \u003e1=темнее средние тона)", + "color_strip.gradient.add_stop": "+ Добавить", + "color_strip.gradient.bidir.hint": "Добавить второй цвет справа от этой остановки для создания резкого перехода в градиенте.", + "color_strip.gradient.min_stops": "Градиент должен содержать не менее 2 остановок", + "color_strip.gradient.position": "Позиция (0.0–1.0)", + "color_strip.gradient.preset": "Пресет:", + "color_strip.gradient.preset.apply": "Применить", + "color_strip.gradient.preset.aurora": "Аврора", + "color_strip.gradient.preset.cool": "Холодный", + "color_strip.gradient.preset.custom": "— Свой —", + "color_strip.gradient.preset.deleted": "Пресет удалён", + "color_strip.gradient.preset.fire": "Огонь", + "color_strip.gradient.preset.forest": "Лес", + "color_strip.gradient.preset.hint": "Загрузить готовую палитру градиента. Выбор пресета заменяет текущие остановки.", + "color_strip.gradient.preset.ice": "Лёд", + "color_strip.gradient.preset.lava": "Лава", + "color_strip.gradient.preset.neon": "Неон", + "color_strip.gradient.preset.ocean": "Океан", + "color_strip.gradient.preset.pastel": "Пастельный", + "color_strip.gradient.preset.rainbow": "Радуга", + "color_strip.gradient.preset.save_button": "Сохранить как пресет…", + "color_strip.gradient.preset.save_prompt": "Введите название пресета:", + "color_strip.gradient.preset.saved": "Пресет сохранён", + "color_strip.gradient.preset.sunset": "Закат", + "color_strip.gradient.preset.warm": "Тёплый", + "color_strip.gradient.preview": "Градиент:", + "color_strip.gradient.preview.hint": "Предпросмотр градиента. Нажмите на дорожку маркеров чтобы добавить остановку. Перетащите маркеры для изменения позиции.", + "color_strip.gradient.stops": "Цветовые остановки:", + "color_strip.gradient.stops.hint": "Каждая остановка задаёт цвет в относительной позиции (0.0 = начало, 1.0 = конец). Кнопка ↔ добавляет цвет справа для создания резкого перехода.", + "color_strip.gradient.stops_count": "остановок", + "color_strip.interpolation": "Режим цвета:", + "color_strip.interpolation.average": "Среднее", + "color_strip.interpolation.average.desc": "Смешивает все пиксели в усреднённый цвет", + "color_strip.interpolation.dominant": "Доминирующий", + "color_strip.interpolation.dominant.desc": "Использует самый частый цвет в выборке", + "color_strip.interpolation.hint": "Как вычислять цвет светодиода по пикселям рамки", + "color_strip.interpolation.median": "Медиана", + "color_strip.interpolation.median.desc": "Берёт средний цвет, игнорируя выбросы", + "color_strip.led_count": "Количество LED:", + "color_strip.led_count.hint": "Общее число светодиодов на физической полосе. Для источников экрана: 0 = автоматически из калибровки (светодиоды за ТВ будут чёрными). Для статического цвета: укажите точное количество светодиодов устройства.", + "color_strip.leds": "Количество светодиодов", + "color_strip.mapped.add_zone": "+ Добавить зону", + "color_strip.mapped.error.no_source": "Для каждой зоны должен быть выбран источник", + "color_strip.mapped.select_source": "Поиск источников...", + "color_strip.mapped.zone_end": "Конец LED", + "color_strip.mapped.zone_reverse": "Реверс", + "color_strip.mapped.zone_source": "Источник", + "color_strip.mapped.zone_start": "Начало LED", + "color_strip.mapped.zones": "Зоны:", + "color_strip.mapped.zones.hint": "Каждая зона привязывает источник цветовой полосы к определённому диапазону LED. Зоны размещаются рядом — промежутки остаются чёрными.", + "color_strip.mapped.zones_count": "зон", + "color_strip.math_wave.add_wave": "+ Добавить волну", + "color_strip.math_wave.amplitude": "Амплитуда", + "color_strip.math_wave.error.no_waves": "Добавьте хотя бы один слой волны.", + "color_strip.math_wave.frequency": "Частота", + "color_strip.math_wave.gradient": "Цветовой градиент:", + "color_strip.math_wave.gradient.hint": "Градиент для окраски волнового выхода. Значения волны отображаются на позиции вдоль этого градиента.", + "color_strip.math_wave.offset": "Смещение", + "color_strip.math_wave.phase": "Фаза", + "color_strip.math_wave.speed": "Скорость:", + "color_strip.math_wave.speed.hint": "Множитель скорости анимации. Более высокие значения ускоряют движение волны.", + "color_strip.math_wave.waveform": "Форма волны", + "color_strip.math_wave.waveform.sawtooth": "Пилообразная", + "color_strip.math_wave.waveform.sine": "Синусоида", + "color_strip.math_wave.waveform.square": "Прямоугольная", + "color_strip.math_wave.waveform.triangle": "Треугольная", + "color_strip.math_wave.waves": "Слои волн:", + "color_strip.math_wave.waves.hint": "Добавьте несколько слоёв волн, которые комбинируются вместе. Каждая волна имеет собственную форму, частоту, амплитуду, фазу и смещение.", + "color_strip.name": "Название:", + "color_strip.name.placeholder": "Настенная полоса", + "color_strip.notification.app_colors": "Цвета приложений", + "color_strip.notification.app_colors.add": "+ Добавить", + "color_strip.notification.app_colors.hint": "Индивидуальные цвета для приложений. Каждая строка связывает имя приложения с цветом уведомления.", + "color_strip.notification.app_colors.label": "Назначения цветов:", + "color_strip.notification.app_count": "прилож.", + "color_strip.notification.app_overrides": "Настройки приложений", + "color_strip.notification.app_overrides.add": "+ Добавить", + "color_strip.notification.app_overrides.app_placeholder": "Имя приложения", + "color_strip.notification.app_overrides.hint": "Индивидуальные настройки цвета и звука для каждого приложения.", + "color_strip.notification.app_overrides.label": "Переопределения:", + "color_strip.notification.default_color": "Цвет по умолчанию:", + "color_strip.notification.default_color.hint": "Цвет, когда для приложения нет специфического назначения цвета.", + "color_strip.notification.duration": "Длительность (мс):", + "color_strip.notification.duration.hint": "Как долго длится эффект уведомления в миллисекундах.", + "color_strip.notification.effect": "Эффект:", + "color_strip.notification.effect.flash": "Вспышка", + "color_strip.notification.effect.flash.desc": "Мгновенное включение, линейное затухание", + "color_strip.notification.effect.hint": "Визуальный эффект при уведомлении. Вспышка — линейное затухание, Пульс — плавная волна, Волна — заполнение и затухание.", + "color_strip.notification.effect.pulse": "Пульс", + "color_strip.notification.effect.pulse.desc": "Плавное свечение колоколом", + "color_strip.notification.effect.sweep": "Волна", + "color_strip.notification.effect.sweep.desc": "Заполняет слева направо, затем гаснет", + "color_strip.notification.endpoint": "Вебхук:", + "color_strip.notification.endpoint.hint": "URL для запуска уведомлений из внешних систем. POST с JSON телом: {\"app\": \"AppName\", \"color\": \"#FF0000\"}.", + "color_strip.notification.filter_list": "Список приложений:", + "color_strip.notification.filter_list.hint": "Одно имя приложения на строку. Используйте «Обзор» для выбора из запущенных процессов.", + "color_strip.notification.filter_list.placeholder": "Discord\nSlack\nTelegram", + "color_strip.notification.filter_mode": "Фильтр приложений:", + "color_strip.notification.filter_mode.blacklist": "Чёрный список", + "color_strip.notification.filter_mode.blacklist.desc": "Все кроме указанных приложений", + "color_strip.notification.filter_mode.hint": "Фильтр уведомлений по имени приложения. Выкл = все, Белый список = только указанные, Чёрный список = все кроме указанных.", + "color_strip.notification.filter_mode.off": "Выкл", + "color_strip.notification.filter_mode.off.desc": "Принимать все уведомления", + "color_strip.notification.filter_mode.whitelist": "Белый список", + "color_strip.notification.filter_mode.whitelist.desc": "Только указанные приложения", + "color_strip.notification.history.empty": "Уведомления ещё не захвачены", + "color_strip.notification.history.error": "Не удалось загрузить историю уведомлений", + "color_strip.notification.history.filtered": "Потоков отфильтровано", + "color_strip.notification.history.fired": "Потоков запущено", + "color_strip.notification.history.hint": "Последние ОС-уведомления, захваченные слушателем (новейшие сверху). До 50 записей.", + "color_strip.notification.history.refresh": "Обновить", + "color_strip.notification.history.title": "История уведомлений", + "color_strip.notification.history.unavailable": "Слушатель уведомлений ОС недоступен на этой платформе", + "color_strip.notification.history.unknown_app": "Неизвестное приложение", + "color_strip.notification.os_listener": "Слушать системные уведомления:", + "color_strip.notification.os_listener.hint": "Когда включено, источник автоматически срабатывает при появлении уведомления рабочего стола (Windows toast / Linux D-Bus). Требует разрешения на доступ к уведомлениям.", + "color_strip.notification.save_first": "Сначала сохраните источник, чтобы увидеть URL вебхука.", + "color_strip.notification.search_apps": "Поиск приложений…", + "color_strip.notification.sound": "Звук", + "color_strip.notification.sound.app_name_placeholder": "Имя приложения", + "color_strip.notification.sound.app_sounds": "Звуки приложений:", + "color_strip.notification.sound.app_sounds.add": "+ Добавить", + "color_strip.notification.sound.app_sounds.hint": "Переопределение звука и громкости для конкретных приложений. Пустой звук = отключить для этого приложения.", + "color_strip.notification.sound.asset": "Звуковой ассет:", + "color_strip.notification.sound.asset.hint": "Выберите звуковой ассет для воспроизведения при уведомлении. Оставьте пустым для тишины.", + "color_strip.notification.sound.none": "Нет (без звука)", + "color_strip.notification.sound.search": "Поиск звуков…", + "color_strip.notification.sound.volume": "Громкость:", + "color_strip.notification.sound.volume.hint": "Общая громкость звуков уведомлений (0–100%).", + "color_strip.notification.test": "Тестовое уведомление", + "color_strip.notification.test.error": "Не удалось отправить уведомление", + "color_strip.notification.test.no_streams": "Нет запущенных потоков для этого источника", + "color_strip.notification.test.ok": "Уведомление отправлено", + "color_strip.palette.aurora": "Аврора", + "color_strip.palette.fire": "Огонь", + "color_strip.palette.forest": "Лес", + "color_strip.palette.ice": "Лёд", + "color_strip.palette.lava": "Лава", + "color_strip.palette.ocean": "Океан", + "color_strip.palette.rainbow": "Радуга", + "color_strip.palette.sunset": "Закат", + "color_strip.picture_source": "Источник изображения:", + "color_strip.picture_source.hint": "Источник захвата экрана для расчёта цветов светодиодов", + "color_strip.preview.connected": "Подключено", + "color_strip.preview.connecting": "Подключение...", + "color_strip.preview.not_connected": "Не подключено", + "color_strip.preview.save_first": "Сначала сохраните источник — для предпросмотра нужна калибровка", + "color_strip.preview.title": "Предпросмотр", + "color_strip.preview.unsupported": "Предпросмотр недоступен для этого типа источника", + "color_strip.processed.error.no_input": "Выберите входной источник", + "color_strip.processed.input": "Источник:", + "color_strip.processed.input.hint": "Источник цветовой полосы, вывод которого будет обработан", + "color_strip.processed.template": "Шаблон обработки:", + "color_strip.processed.template.hint": "Цепочка фильтров для применения к выводу входного источника", + "color_strip.saturation": "Насыщенность:", + "color_strip.saturation.hint": "Насыщенность цвета (0=оттенки серого, 1=без изменений, 2=двойная насыщенность)", + "color_strip.select_type": "Выберите тип цветовой полосы", + "color_strip.single_color": "Цвет:", + "color_strip.single_color.hint": "Один сплошной цвет, который будет отправлен на все светодиоды полосы.", + "color_strip.smoothing": "Сглаживание:", + "color_strip.smoothing.hint": "Временное смешивание кадров (0=без смешивания, 1=полное). Уменьшает мерцание.", + "color_strip.test.apply": "Применить", + "color_strip.test.composite": "Композит", + "color_strip.test.connecting": "Подключение...", + "color_strip.test.error": "Не удалось подключиться к потоку предпросмотра", + "color_strip.test.fps": "FPS:", + "color_strip.test.led_count": "Кол-во LED:", + "color_strip.test.receive_fps": "Частота приёма", + "color_strip.test.title": "Предпросмотр", + "color_strip.test_device": "Тестировать на устройстве:", + "color_strip.test_device.hint": "Выберите устройство для отправки тестовых пикселей при нажатии на рамку", + "color_strip.type": "Тип:", + "color_strip.type.api_input": "API-ввод", + "color_strip.type.api_input.desc": "Приём цветов от внешних приложений", + "color_strip.type.api_input.hint": "Принимает массивы цветов LED от внешних клиентов через REST POST или WebSocket. Используйте для интеграции с собственным ПО, домашней автоматизацией или любой системой, способной отправлять HTTP-запросы.", + "color_strip.type.audio": "Аудиореактив", + "color_strip.type.audio.desc": "LED от аудиосигнала", + "color_strip.type.audio.hint": "Цвета LED управляются аудиосигналом в реальном времени — системный звук или микрофон.", + "color_strip.type.candlelight": "Свечи", + "color_strip.type.candlelight.desc": "Реалистичная имитация мерцания свечей", + "color_strip.type.candlelight.hint": "Реалистичное мерцание свечей с тёплыми тонами и органическими паттернами.", + "color_strip.type.composite": "Композит", + "color_strip.type.composite.desc": "Наложение и смешивание источников", + "color_strip.type.composite.hint": "Наложение нескольких источников цветовой ленты как слоёв с режимами смешивания и прозрачностью.", + "color_strip.type.daylight": "Дневной цикл", + "color_strip.type.daylight.desc": "Имитация естественного дневного света за 24 часа", + "color_strip.type.daylight.hint": "Имитирует цветовую температуру солнца в течение суток — от тёплого рассвета до прохладного дневного света, заката и ночи.", + "color_strip.type.effect": "Эффект", + "color_strip.type.effect.desc": "Процедурные эффекты: огонь, плазма, аврора", + "color_strip.type.effect.hint": "Процедурные LED-эффекты (огонь, метеор, плазма, шум, аврора), генерируемые в реальном времени.", + "color_strip.type.game_event": "Игровое событие", + "color_strip.type.game_event.desc": "LED-эффекты по игровым событиям", + "color_strip.type.gradient": "Градиент", + "color_strip.type.gradient.desc": "Плавный переход цветов по ленте", + "color_strip.type.hint": "Источник изображения получает цвета светодиодов из захвата экрана. Один цвет заполняет все светодиоды одним постоянным цветом. Градиент распределяет цветовой градиент по всем светодиодам. Смена цвета плавно циклически переключается между заданными цветами. Композит накладывает несколько источников как смешанные слои. Аудиореактив управляет LED от аудиосигнала в реальном времени. API-ввод принимает массивы цветов LED от внешних клиентов через REST или WebSocket.", + "color_strip.type.mapped": "Маппинг", + "color_strip.type.mapped.desc": "Назначение источников на зоны LED", + "color_strip.type.mapped.hint": "Назначает разные источники цветовой полосы на разные диапазоны LED (зоны). В отличие от композита, маппинг размещает источники рядом друг с другом.", + "color_strip.type.math_wave": "Математическая волна", + "color_strip.type.math_wave.desc": "Генератор математических волн с цветовым градиентом", + "color_strip.type.notification": "Уведомления", + "color_strip.type.notification.desc": "Разовый эффект по вебхуку", + "color_strip.type.notification.hint": "Вспышка, пульс или волна при срабатывании через вебхук. Предназначен для использования как слой в композитном источнике.", + "color_strip.type.picture": "Источник изображения", + "color_strip.type.picture.desc": "Цвета из захвата экрана", + "color_strip.type.picture_advanced": "Мультимонитор", + "color_strip.type.picture_advanced.desc": "Калибровка линиями по нескольким мониторам", + "color_strip.type.processed": "Обработанный", + "color_strip.type.processed.desc": "Применить шаблон обработки к другому источнику", + "color_strip.type.processed.hint": "Оборачивает существующий источник цветовой полосы и пропускает его вывод через цепочку фильтров.", + "color_strip.type.single_color": "Один цвет", + "color_strip.type.single_color.desc": "Заливка одним цветом", + "color_strip.type.weather": "Погода", + "color_strip.type.weather.desc": "Погодно-реактивные амбиентные цвета", + "color_strip.type.weather.hint": "Отображает текущие погодные условия в амбиентные цвета LED. Требуется сущность Источник погоды.", + "color_strip.updated": "Источник цветовой полосы обновлён", + "color_strip.weather.error.no_source": "Выберите источник погоды", + "color_strip.weather.source": "Источник погоды:", + "color_strip.weather.source.hint": "Источник метеоданных. Сначала создайте его во вкладке Погода.", + "color_strip.weather.speed": "Скорость анимации:", + "color_strip.weather.speed.hint": "Скорость дрейфа цветов. Выше = быстрее.", + "color_strip.weather.temperature_influence": "Влияние температуры:", + "color_strip.weather.temperature_influence.hint": "Насколько температура смещает палитру в тёплую/холодную сторону. 0 = чистые цвета условий, 1 = сильное смещение.", + "common.apply": "Применить", + "common.back": "Назад", + "common.cancel": "Отмена", + "common.card_color": "Цвет карточки", + "common.clone": "Клонировать", + "common.daylight_tz.detected": "Определён браузером", + "common.daylight_tz.detected_label": "Авто-определение", + "common.daylight_tz.region.africa": "Африка", + "common.daylight_tz.region.americas": "Америка", + "common.daylight_tz.region.asia": "Азия", + "common.daylight_tz.region.europe": "Европа", + "common.daylight_tz.region.me": "Ближний Восток", + "common.daylight_tz.region.pacific": "Тихий океан", + "common.daylight_tz.region.utc": "Универсальное", + "common.daylight_tz.search": "Поиск по поясам, городам, регионам…", + "common.daylight_tz.system": "Системный по умолчанию", + "common.daylight_tz.system_desc": "Часы сервера — без переопределения", + "common.delete": "Удалить", + "common.edit": "Редактировать", + "common.hide": "Скрыть карточку", + "common.led_preview": "Превью LED", + "common.loading": "Загрузка...", + "common.more_actions": "Ещё действия", + "common.none": "Нет", + "common.none_no_cspt": "Нет (без шаблона обработки)", + "common.none_no_input": "Нет (без источника)", + "common.none_own_speed": "Нет (своя скорость)", + "common.remove": "Убрать", + "common.start": "ПУСК", + "common.stop": "СТОП", + "common.undo": "Отменить", + "confirm.no": "Нет", + "confirm.stop_all": "Остановить все запущенные цели?", + "confirm.title": "Подтверждение", + "confirm.turn_off_device": "Выключить это устройство?", + "confirm.turn_off_ha_light": "Выключить все привязанные лампы?", + "confirm.yes": "Да", + "css_processing.add": "Добавить Шаблон Обработки Полос", + "css_processing.created": "Шаблон обработки полос создан", + "css_processing.delete.confirm": "Вы уверены, что хотите удалить этот шаблон обработки полос?", + "css_processing.deleted": "Шаблон обработки полос удалён", + "css_processing.description_label": "Описание (необязательно):", + "css_processing.description_placeholder": "Опишите этот шаблон...", + "css_processing.edit": "Редактировать Шаблон Обработки Полос", + "css_processing.error.clone_failed": "Не удалось клонировать шаблон обработки полос", + "css_processing.error.delete": "Ошибка удаления шаблона обработки полос", + "css_processing.error.load": "Ошибка загрузки шаблона обработки полос", + "css_processing.error.required": "Заполните все обязательные поля", + "css_processing.name": "Имя Шаблона:", + "css_processing.name_placeholder": "Мой Шаблон Обработки Полос", + "css_processing.title": "Шаблоны Обработки Полос", + "css_processing.updated": "Шаблон обработки полос обновлён", + "dashboard.customize.anim": "Анимации", + "dashboard.customize.anim.full": "Полные", + "dashboard.customize.anim.off": "Выкл", + "dashboard.customize.anim.reduced": "Снижены", + "dashboard.customize.cell_drag_help": "Перетащите строку, чтобы изменить порядок ячеек в полосе производительности.", + "dashboard.customize.collapse_default.off": "Развёрнуто по умолчанию", + "dashboard.customize.collapse_default.on": "Свёрнуто по умолчанию", + "dashboard.customize.density.comfortable": "Просторно", + "dashboard.customize.density.compact": "Компактно", + "dashboard.customize.density.dense": "Плотно", + "dashboard.customize.drag_help": "Перетащите строки или используйте ↑/↓.", + "dashboard.customize.export": "Экспорт", + "dashboard.customize.exported": "Настройки экспортированы", + "dashboard.customize.fixed_top": "Закреплено сверху", + "dashboard.customize.global": "Общие", + "dashboard.customize.hide": "Скрыть", + "dashboard.customize.import": "Импорт", + "dashboard.customize.import_failed": "Не удалось импортировать настройки", + "dashboard.customize.imported": "Настройки импортированы", + "dashboard.customize.mode.app": "Прил", + "dashboard.customize.mode.both": "Оба", + "dashboard.customize.mode.inherit": "Наслед.", + "dashboard.customize.mode.system": "Сис", + "dashboard.customize.mode_short": "РЕЖ", + "dashboard.customize.modified": "Изменено", + "dashboard.customize.perf_cells": "Системный мониторинг", + "dashboard.customize.perf_mode": "Режим перф.", + "dashboard.customize.preset.diagnostics": "Диагностика", + "dashboard.customize.preset.operator": "Оператор", + "dashboard.customize.preset.showrunner": "Шоу", + "dashboard.customize.preset.studio": "Студия", + "dashboard.customize.preset.tv": "ТВ", + "dashboard.customize.presets": "Пресеты", + "dashboard.customize.reset": "Сбросить", + "dashboard.customize.reset_confirm": "Сбросить настройки панели к пресету «Студия»?", + "dashboard.customize.scale": "Шкала Y", + "dashboard.customize.scale_short": "ШКЛ", + "dashboard.customize.sections": "Секции", + "dashboard.customize.show": "Показать", + "dashboard.customize.title": "Настройка панели", + "dashboard.customize.width": "Ширина", + "dashboard.customize.width.centered": "По центру", + "dashboard.customize.width.full": "Полная", + "dashboard.customize.width.narrow": "Узкая", + "dashboard.customize.window": "Окно выборки", + "dashboard.customize.window_short": "ОКН", + "dashboard.customize.yscale.auto": "Авто", + "dashboard.customize.yscale.fixed": "Фикс.", + "dashboard.customize.yscale.log": "Лог.", + "dashboard.device": "Устройство", + "dashboard.device.chip": "Чип:", + "dashboard.error.automation_toggle_failed": "Не удалось переключить автоматизацию", + "dashboard.error.start_failed": "Не удалось запустить обработку", + "dashboard.error.stop_all": "Не удалось остановить все цели", + "dashboard.error.stop_failed": "Не удалось остановить обработку", + "dashboard.errors": "Ошибки", + "dashboard.failed": "Не удалось загрузить обзор", + "dashboard.fps": "FPS", + "dashboard.no_targets": "Нет настроенных целей", + "dashboard.perf.active_patches": "Активные каналы", + "dashboard.perf.color": "Цвет графика", + "dashboard.perf.cpu": "ЦП", + "dashboard.perf.device_latency": "Задержка устройств", + "dashboard.perf.devices": "Устройства", + "dashboard.perf.errors": "Ошибки", + "dashboard.perf.gpu": "ГП", + "dashboard.perf.mode.app": "Приложение", + "dashboard.perf.mode.both": "Оба", + "dashboard.perf.mode.system": "Система", + "dashboard.perf.network": "Сеть", + "dashboard.perf.patches.empty.idle": "Готов к запуску", + "dashboard.perf.patches.empty.none": "Каналов пока нет", + "dashboard.perf.ram": "ОЗУ", + "dashboard.perf.send_timing": "Время отправки", + "dashboard.perf.temp": "Температура", + "dashboard.perf.temp.install_lhm": "В Windows нет встроенного API для температуры CPU. Установите LibreHardwareMonitor и включите «Publish to WMI», чтобы видеть живые показания.", + "dashboard.perf.total_capture_fps": "Общий FPS источников", + "dashboard.perf.total_capture_fps_actual": "Общий FPS захвата", + "dashboard.perf.total_fps": "Общий FPS", + "dashboard.perf.unavailable": "недоступно", + "dashboard.poll_interval": "Интервал обновления", + "dashboard.recent_activity.view_all": "Посмотреть все", + "dashboard.section.automations": "Автоматизации", + "dashboard.section.performance": "Производительность системы", + "dashboard.section.playlists": "Плейлисты", + "dashboard.section.recent_activity": "Последняя активность", + "dashboard.section.running": "Запущенные", + "dashboard.section.scenes": "Пресеты сцен", + "dashboard.section.stopped": "Остановленные", + "dashboard.section.sync_clocks": "Синхронные часы", + "dashboard.section.targets": "Каналы", + "dashboard.stop_all": "Остановить все", + "dashboard.targets": "Цели", + "dashboard.title": "Обзор", + "dashboard.type.kc": "Цвета клавиш", + "dashboard.type.led": "LED", + "dashboard.uptime": "Время работы", + "demo.badge": "ДЕМО", + "demo.banner": "Вы в демо-режиме — все устройства и данные виртуальные. Реальное оборудование не используется.", + "device.added": "Устройство успешно добавлено", + "device.baud_rate": "Скорость порта:", + "device.baud_rate.hint": "Скорость серийного соединения. Выше = больше FPS, но требует соответствия скетчу Arduino.", + "device.ble.family": "Протокол:", + "device.ble.family.govee.desc": "Ленты Govee H6xxx — только без AES-шифрования", + "device.ble.family.hint": "Какой BLE-протокол использует контроллер. Выбирайте по названию приложения, которым обычно управляете.", + "device.ble.family.sp110e.desc": "Адресуемые контроллеры — приложение LED Hue / SP110E", + "device.ble.family.triones.desc": "Одноцветные контроллеры — HappyLighting / LEDnet", + "device.ble.family.zengge.desc": "Одноцветные контроллеры — iLightsIn / Mohuan", + "device.ble.govee_key": "Ключ AES Govee (hex):", + "device.ble.govee_key.hint": "Необязательно. Новая прошивка Govee требует AES-ключ под конкретную модель — оставьте пустым для старой прошивки.", + "device.ble.govee_key.placeholder": "32 символа hex, напр. 0102…1f20", + "device.ble.url": "BLE адрес:", + "device.ble.url.hint": "MAC-адрес (Windows/Linux) или UUID (macOS) с префиксом ble://", + "device.brightness": "Яркость", + "device.button.add": "Добавить Устройство", + "device.button.calibrate": "Калибровка", + "device.button.capture_settings": "Настройки захвата", + "device.button.ping": "Пинг устройства", + "device.button.power_off": "Выключить", + "device.button.remove": "Удалить", + "device.button.settings": "Основные настройки", + "device.button.start": "Запустить", + "device.button.stop": "Остановить", + "device.button.stream_selector": "Настройки источника", + "device.button.webui": "Открыть веб-интерфейс устройства", + "device.chroma.device_type": "Peripheral Type:", + "device.chroma.device_type.hint": "Which Razer peripheral to control via Chroma SDK", + "device.css_processing_template": "Шаблон Обработки Полос:", + "device.css_processing_template.hint": "Шаблон обработки по умолчанию, применяемый ко всем цветовым полосам на этом устройстве", + "device.ddp.color_order.bgr.desc": "BGR byte order", + "device.ddp.color_order.brg.desc": "BRG byte order", + "device.ddp.color_order.gbr.desc": "GBR byte order", + "device.ddp.color_order.grb.desc": "WS2812/WS2812B native order", + "device.ddp.color_order.rbg.desc": "RBG byte order", + "device.ddp.color_order.rgb.desc": "Standard RGB byte order", + "device.ddp.url": "IP-адрес:", + "device.ddp.url.hint": "Адрес DDP-приёмника. Порт по умолчанию — 4048.", + "device.ddp.url.placeholder": "192.168.1.50", + "device.ddp_color_order": "Порядок цветов:", + "device.ddp_color_order.hint": "Порядок байт каналов на линии. Большинство DDP-приёмников ожидают RGB.", + "device.ddp_destination_id": "ID назначения:", + "device.ddp_destination_id.hint": "Идентификатор назначения DDP (1 = дисплей).", + "device.ddp_port": "Порт DDP:", + "device.ddp_port.hint": "UDP-порт (0 = протокольный по умолчанию 4048).", + "device.display": "Дисплей:", + "device.dmx.url": "IP адрес:", + "device.dmx.url.hint": "IP адрес DMX-узла (напр. 192.168.1.50)", + "device.dmx.url.placeholder": "192.168.1.50", + "device.dmx_protocol": "Протокол DMX:", + "device.dmx_protocol.artnet.desc": "UDP unicast, порт 6454", + "device.dmx_protocol.hint": "Art-Net использует UDP порт 6454, sACN (E1.31) — UDP порт 5568", + "device.dmx_protocol.sacn.desc": "Multicast/unicast, порт 5568", + "device.dmx_start_channel": "Начальный канал:", + "device.dmx_start_channel.hint": "Первый DMX-канал в юниверсе (1-512)", + "device.dmx_start_universe": "Начальный Universe:", + "device.dmx_start_universe.hint": "Первый DMX-юниверс (0-32767). Дополнительные юниверсы используются автоматически при \u003e170 светодиодах.", + "device.error.brightness": "Не удалось обновить яркость", + "device.error.clone_failed": "Не удалось клонировать устройство", + "device.error.load_failed": "Не удалось загрузить устройство", + "device.error.power_off_failed": "Не удалось выключить устройство", + "device.error.remove_failed": "Не удалось удалить устройство", + "device.error.required": "Пожалуйста, заполните все поля", + "device.error.save": "Не удалось сохранить настройки", + "device.error.settings_load_failed": "Не удалось загрузить настройки устройства", + "device.error.update": "Не удалось обновить устройство", + "device.espnow.channel": "WiFi Channel:", + "device.espnow.channel.hint": "WiFi channel (1-14). Must match the receiver\u0027s channel.", + "device.espnow.peer_mac": "Peer MAC:", + "device.espnow.peer_mac.hint": "MAC address of the remote ESP32 receiver (e.g. AA:BB:CC:DD:EE:FF)", + "device.fps": "FPS:", + "device.gamesense.device_type": "Peripheral Type:", + "device.gamesense.device_type.hint": "Which SteelSeries peripheral to control via GameSense", + "device.gamesense.peripheral.headset": "Headset", + "device.gamesense.peripheral.headset.desc": "Headset earcup lighting", + "device.gamesense.peripheral.indicator": "Indicator", + "device.gamesense.peripheral.indicator.desc": "OLED/LED status indicator", + "device.gamesense.peripheral.keyboard": "Keyboard", + "device.gamesense.peripheral.keyboard.desc": "Per-key RGB illumination", + "device.gamesense.peripheral.mouse": "Mouse", + "device.gamesense.peripheral.mouse.desc": "Mouse RGB zones", + "device.gamesense.peripheral.mousepad": "Mousepad", + "device.gamesense.peripheral.mousepad.desc": "Mousepad edge lighting zones", + "device.govee.url": "IP-адрес:", + "device.govee.url.hint": "IP-адрес устройства Govee в локальной сети. Сначала включите LAN Control в приложении Govee Home (Устройство → ⚙ → LAN Control), иначе лампа не ответит.", + "device.govee.url.placeholder": "192.168.1.50", + "device.govee_min_interval": "Мин. интервал обновления:", + "device.govee_min_interval.hint": "Локальный лимит частоты команд (мс). UDP fire-and-forget справляется с быстрыми обновлениями; по умолчанию 50 мс ≈ 20 Гц.", + "device.group.add_child": "+ Добавить устройство", + "device.group.children": "Дочерние устройства:", + "device.group.children.hint": "Выберите устройства для группы. Порядок важен в режиме последовательности.", + "device.group.error.no_children": "Добавьте хотя бы одно устройство в группу.", + "device.group.mode": "Режим группы:", + "device.group.mode.hint": "Последовательный — LEDs соединяются друг за другом. Независимый — полная лента отражается на каждое устройство.", + "device.group.mode.independent": "Независимый", + "device.group.mode.independent.desc": "Дублировать полосу на каждое устройство отдельно", + "device.group.mode.sequence": "Последовательный", + "device.group.mode.sequence.desc": "Объединить светодиоды в одну длинную полосу", + "device.group.move_down": "Вниз", + "device.group.move_up": "Вверх", + "device.group.select_device": "Выберите устройство", + "device.health.checking": "Проверка...", + "device.health.offline": "Недоступен", + "device.health.online": "Онлайн", + "device.health.streaming_unreachable": "Недоступен во время стриминга", + "device.hue.client_key": "Client Key:", + "device.hue.client_key.hint": "Entertainment API client key (hex string from pairing)", + "device.hue.group_id": "Entertainment Group:", + "device.hue.group_id.hint": "Entertainment configuration ID from your Hue bridge", + "device.hue.url": "Bridge IP:", + "device.hue.url.hint": "IP address of your Hue bridge", + "device.hue.username": "Bridge Username:", + "device.hue.username.hint": "Hue bridge application key from pairing", + "device.icon.add": "Добавить", + "device.icon.audio_file": "Аудиофайл", + "device.icon.bath": "Ванная", + "device.icon.battery": "Батарея", + "device.icon.bot": "Бот", + "device.icon.briefcase": "Портфель", + "device.icon.bulb_off": "Лампа выключена", + "device.icon.calendar": "Календарь", + "device.icon.camcorder": "Видеокамера", + "device.icon.candle": "Свеча", + "device.icon.cat.all": "Все", + "device.icon.cat.ambience": "Атмосфера", + "device.icon.cat.controls": "Управление", + "device.icon.cat.hardware": "Оборудование", + "device.icon.cat.lighting": "Освещение", + "device.icon.cat.media": "Медиа", + "device.icon.cat.nature": "Природа", + "device.icon.cat.office": "Офис", + "device.icon.cat.rooms": "Комнаты", + "device.icon.cat.signal": "Сигнал", + "device.icon.cat.status": "Состояние", + "device.icon.cat.weather": "Погода", + "device.icon.ceiling": "Потолочный светильник", + "device.icon.change": "Изменить иконку…", + "device.icon.check": "Галочка", + "device.icon.checklist": "Список", + "device.icon.choose": "Выбрать иконку…", + "device.icon.clock": "Часы", + "device.icon.code": "Код", + "device.icon.coffee": "Кофе", + "device.icon.color_toggle": "Цвет", + "device.icon.desk_lamp": "Настольная лампа", + "device.icon.dim": "Приглушённый свет", + "device.icon.disc": "Диск", + "device.icon.doc": "Документ", + "device.icon.empty": "Иконки не найдены.", + "device.icon.entity.asset": "Ассет", + "device.icon.entity.audio_processing_template": "Шаблон обработки аудио", + "device.icon.entity.audio_source": "Источник аудио", + "device.icon.entity.audio_template": "Аудиошаблон", + "device.icon.entity.automation": "Автоматизация", + "device.icon.entity.capture_template": "Шаблон захвата", + "device.icon.entity.color_strip_source": "Цветная полоска", + "device.icon.entity.cspt": "Шаблон обработки полоски", + "device.icon.entity.device": "Устройство", + "device.icon.entity.game_integration": "Игровая интеграция", + "device.icon.entity.gradient": "Градиент", + "device.icon.entity.ha_light_target": "HA-светильник", + "device.icon.entity.ha_source": "Источник Home Assistant", + "device.icon.entity.http_endpoint": "HTTP-эндпоинт", + "device.icon.entity.mqtt_source": "Источник MQTT", + "device.icon.entity.pattern_template": "Шаблон паттерна", + "device.icon.entity.picture_source": "Источник изображения", + "device.icon.entity.pp_template": "Шаблон постобработки", + "device.icon.entity.scene_playlist": "Плейлист", + "device.icon.entity.scene_preset": "Сцена", + "device.icon.entity.sync_clock": "Часы синхронизации", + "device.icon.entity.target": "LED-цель", + "device.icon.entity.value_source": "Источник значения", + "device.icon.entity.weather_source": "Источник погоды", + "device.icon.error.save_failed": "Не удалось сохранить иконку", + "device.icon.eyebrow": "Иконка карточки", + "device.icon.flashlight": "Фонарик", + "device.icon.flower": "Цветок", + "device.icon.fog": "Туман", + "device.icon.for": "для", + "device.icon.garage": "Гараж", + "device.icon.hashtag": "Хэштег", + "device.icon.help": "Справка", + "device.icon.hint": "↵ Применить · Esc Отмена", + "device.icon.image": "Изображение", + "device.icon.inherited_from": "Унаследовано от %s", + "device.icon.key": "Ключ", + "device.icon.kitchen": "Кухня", + "device.icon.laptop": "Ноутбук", + "device.icon.leaf": "Лист", + "device.icon.link": "Ссылка", + "device.icon.lock": "Замок", + "device.icon.mail": "Почта", + "device.icon.mcu": "Микроконтроллер", + "device.icon.mountain": "Гора", + "device.icon.off": "Отключено", + "device.icon.ok": "ОК", + "device.icon.outdoor": "Улица", + "device.icon.override_inherited": "Заменить унаследованную иконку…", + "device.icon.package": "Устройство", + "device.icon.phone": "Телефон", + "device.icon.point": "Точечный свет", + "device.icon.projector": "Проектор", + "device.icon.pulse": "Пульс", + "device.icon.rain": "Дождь", + "device.icon.recent": "Недавние", + "device.icon.refresh": "Обновить", + "device.icon.remove": "Удалить иконку", + "device.icon.ring": "Кольцевая лампа", + "device.icon.router": "Маршрутизатор", + "device.icon.saved": "Иконка сохранена", + "device.icon.search": "Поиск", + "device.icon.search.placeholder": "Поиск иконок…", + "device.icon.send": "Отправить", + "device.icon.server": "Сервер", + "device.icon.settings": "Настройки", + "device.icon.shield": "Щит", + "device.icon.show": "Показать", + "device.icon.slider": "Слайдер", + "device.icon.snow": "Снег", + "device.icon.snowflake": "Снежинка", + "device.icon.sprout": "Росток", + "device.icon.sunrise": "Восход", + "device.icon.sunset": "Закат", + "device.icon.switch": "Переключатель", + "device.icon.target": "Цель", + "device.icon.thunder": "Гроза", + "device.icon.title": "Выберите иконку", + "device.icon.tool": "Инструмент", + "device.icon.trash": "Корзина", + "device.icon.tree": "Дерево", + "device.icon.trend": "Тренд", + "device.icon.umbrella": "Зонт", + "device.icon.undo": "Отменить", + "device.icon.use_inherited": "Использовать унаследованную", + "device.icon.wall_light": "Настенный светильник", + "device.icon.warning": "Внимание", + "device.icon.watch": "Умные часы", + "device.icon.water_drops": "Капли воды", + "device.icon.waves": "Волны", + "device.icon.webcam": "Веб-камера", + "device.icon.wind": "Ветер", + "device.last_seen.days": "%d д назад", + "device.last_seen.hours": "%d ч назад", + "device.last_seen.just_now": "только что", + "device.last_seen.label": "Последний раз", + "device.last_seen.minutes": "%d мин назад", + "device.last_seen.seconds": "%d с назад", + "device.led_count": "Количество Светодиодов:", + "device.led_count.hint": "Количество светодиодов, настроенных в устройстве", + "device.led_count.hint.auto": "Автоматически определяется из устройства", + "device.led_count_manual.hint": "Количество светодиодов на ленте (должно совпадать с вашим скетчем Arduino)", + "device.led_type": "Тип LED:", + "device.led_type.hint": "RGB (3 канала) или RGBW (4 канала с выделенным белым)", + "device.lifx.url": "IP-адрес:", + "device.lifx.url.hint": "IP-адрес лампы LIFX в локальной сети. UDP-порт 56700 — по умолчанию.", + "device.lifx.url.placeholder": "192.168.1.50", + "device.lifx_min_interval": "Мин. интервал обновления:", + "device.lifx_min_interval.hint": "Локальный лимит частоты команд (мс). LIFX рекомендует ≤20 команд/сек; по умолчанию 50 мс соответствует этому потолку.", + "device.metrics.actual_fps": "Факт. FPS", + "device.metrics.current_fps": "Текущ. FPS", + "device.metrics.device_fps": "Частота обновления устройства", + "device.metrics.errors": "Ошибки", + "device.metrics.frames": "Кадры", + "device.metrics.frames_skipped": "Пропущено", + "device.metrics.keepalive": "Keepalive", + "device.metrics.potential_fps": "Потенц. FPS", + "device.metrics.target_fps": "Целев. FPS", + "device.metrics.timing": "Тайминг пайплайна:", + "device.metrics.uptime": "Время работы", + "device.mqtt_source": "MQTT Брокер:", + "device.mqtt_source.hint": "К какому MQTT-брокеру публикует это устройство. Брокеры настраиваются в разделе Интеграции → Источники MQTT. Оставьте пустым, чтобы использовать первый доступный брокер.", + "device.mqtt_source.none": "— Первый доступный брокер", + "device.mqtt_topic": "MQTT Топик:", + "device.mqtt_topic.hint": "MQTT топик для публикации пиксельных данных (напр. mqtt://ledgrab/device/name)", + "device.mqtt_topic.placeholder": "mqtt://ledgrab/device/гостиная", + "device.name": "Имя Устройства:", + "device.name.placeholder": "ТВ в Гостиной", + "device.nanoleaf.pair.instructions": "Нажмите и удерживайте кнопку питания на контроллере Nanoleaf в течение 5 секунд, пока светодиоды не мигнут, затем нажмите «Начать». Контроллер откроет окно сопряжения на 30 секунд.", + "device.nanoleaf.pair_button": "Сопряжение", + "device.nanoleaf.paired": "Сопряжено", + "device.nanoleaf.token.label": "Токен авторизации:", + "device.nanoleaf.url": "IP-адрес:", + "device.nanoleaf.url.hint": "IP-адрес контроллера Nanoleaf в локальной сети. HTTP-порт 16021 зафиксирован в протоколе.", + "device.nanoleaf.url.placeholder": "192.168.1.50", + "device.nanoleaf_min_interval": "Мин. интервал обновления:", + "device.nanoleaf_min_interval.hint": "Локальный лимит частоты команд (мс). По умолчанию 100 мс ≈ 10 Гц; накладные расходы HTTP ограничивают практический максимум ~20 Гц.", + "device.opc.url": "IP-адрес:", + "device.opc.url.hint": "Адрес приёмника OPC. TCP-порт по умолчанию 7890.", + "device.opc.url.placeholder": "192.168.1.50", + "device.opc_channel": "Канал:", + "device.opc_channel.hint": "Канал OPC (0 — широковещательная отправка на все каналы сервера, 1-255 — конкретный выход).", + "device.openrgb.added_multiple": "Добавлено {count} устройств", + "device.openrgb.mode": "Режим зон:", + "device.openrgb.mode.combined": "Объединённая лента", + "device.openrgb.mode.hint": "Объединённый — все зоны как одна непрерывная LED-лента. Раздельный — каждая зона независимо отображает полный эффект.", + "device.openrgb.mode.separate": "Независимые зоны", + "device.openrgb.url": "OpenRGB URL:", + "device.openrgb.url.hint": "Адрес сервера OpenRGB (напр. openrgb://localhost:6742/0)", + "device.openrgb.zone": "Зоны:", + "device.openrgb.zone.error": "Не удалось загрузить зоны", + "device.openrgb.zone.hint": "Выберите зоны LED для управления (оставьте все неотмеченными для всех зон)", + "device.openrgb.zone.loading": "Загрузка зон…", + "device.ping.error": "Ошибка пинга", + "device.ping.offline": "Устройство недоступно", + "device.ping.online": "Онлайн ({ms}мс)", + "device.power.off_success": "Устройство выключено", + "device.remove.confirm": "Вы уверены, что хотите удалить это устройство?", + "device.removed": "Устройство удалено", + "device.scan": "Автопоиск", + "device.scan.already_added": "Уже добавлено", + "device.scan.empty": "Устройства не найдены", + "device.scan.error": "Ошибка сканирования сети", + "device.scan.selected": "Устройство выбрано", + "device.select_type": "Выберите тип устройства", + "device.send_latency": "Задержка отправки (мс):", + "device.send_latency.hint": "Имитация сетевой/серийной задержки на кадр в миллисекундах", + "device.serial_port": "Серийный порт:", + "device.serial_port.hint": "Выберите COM порт устройства Adalight", + "device.serial_port.none": "Серийные порты не найдены", + "device.serial_port.select": "Выберите порт...", + "device.spi.led_type": "LED Chipset:", + "device.spi.led_type.hint": "Type of addressable LED strip connected to the GPIO/SPI pin", + "device.spi.led_type.sk6812.desc": "Samsung LED, 800 KHz, 3-wire RGB", + "device.spi.led_type.sk6812_rgbw.desc": "SK6812 with dedicated white channel", + "device.spi.led_type.ws2811.desc": "External driver IC, 400 KHz, 12V strips", + "device.spi.led_type.ws2812.desc": "Original WS2812, 800 KHz, 3-wire RGB", + "device.spi.led_type.ws2812b.desc": "Most common, 800 KHz data, 3-wire RGB", + "device.spi.speed": "SPI Speed (Hz):", + "device.spi.speed.hint": "SPI clock speed. 800000 Hz for WS2812, 2400000 Hz for APA102.", + "device.spi.url": "GPIO/SPI Path:", + "device.spi.url.hint": "GPIO pin or SPI device path (e.g. spi://gpio:18)", + "device.started": "Обработка запущена", + "device.status.connected": "Подключено", + "device.status.disconnected": "Отключено", + "device.status.error": "Ошибка", + "device.status.idle": "Ожидание", + "device.status.processing": "Обработка", + "device.stopped": "Обработка остановлена", + "device.stream_selector.hint": "Выберите источник, определяющий что это устройство захватывает и обрабатывает", + "device.stream_selector.label": "Источник:", + "device.stream_selector.none": "-- Источник не назначен --", + "device.stream_selector.saved": "Настройки источника обновлены", + "device.stream_settings.border_width": "Ширина границы (px):", + "device.stream_settings.border_width_hint": "Сколько пикселей от края экрана выбирать для цвета LED (1-100)", + "device.stream_settings.interpolation": "Режим интерполяции:", + "device.stream_settings.interpolation.average": "Среднее", + "device.stream_settings.interpolation.dominant": "Доминантный", + "device.stream_settings.interpolation.median": "Медиана", + "device.stream_settings.interpolation_hint": "Как вычислять цвет LED из выбранных пикселей", + "device.stream_settings.smoothing": "Сглаживание:", + "device.stream_settings.smoothing_hint": "Временное смешивание между кадрами (0=нет, 1=полное). Уменьшает мерцание.", + "device.stream_settings.title": "Настройки источника", + "device.tip.add": "Нажмите, чтобы добавить новое LED устройство", + "device.tip.brightness": "Перетащите для регулировки яркости. Изменения применяются мгновенно.", + "device.tip.identity": "Идентификация устройства — имя, тип и индикатор онлайн/офлайн.", + "device.tip.menu": "Дополнительные действия — дублировать, скрыть или удалить устройство.", + "device.tip.metadata": "Адрес устройства и версия прошивки. Кликните по URL, чтобы открыть веб-интерфейс устройства.", + "device.tip.ping": "Пинг устройства — обновляет статус и измеряет задержку.", + "device.tip.settings": "Открыть настройки устройства — имя, URL, возможности и проверки соединения.", + "device.tip.stream_selector": "Настройки источника и проекции LED для этого устройства", + "device.tutorial.start": "Начать обучение", + "device.type": "Тип устройства:", + "device.type.adalight": "Adalight", + "device.type.adalight.desc": "Серийный протокол для Arduino", + "device.type.ambiled": "AmbiLED", + "device.type.ambiled.desc": "Серийный протокол AmbiLED", + "device.type.ble": "BLE LED контроллер", + "device.type.ble.desc": "Bluetooth LE ленты: SP110E, Triones, Zengge, Govee (один цвет на всю ленту)", + "device.type.chroma": "Razer Chroma", + "device.type.chroma.desc": "Razer peripherals via Chroma SDK", + "device.type.ddp": "DDP", + "device.type.ddp.desc": "Прямая отправка пикселей по UDP (Pixelblaze, ESPixelStick, Falcon)", + "device.type.dmx": "DMX", + "device.type.dmx.desc": "Art-Net / sACN (E1.31) сценическое освещение", + "device.type.espnow": "ESP-NOW", + "device.type.espnow.desc": "Ultra-low-latency via ESP32 gateway", + "device.type.gamesense": "SteelSeries", + "device.type.gamesense.desc": "SteelSeries peripherals via GameSense", + "device.type.govee": "Govee", + "device.type.govee.desc": "Лампа / комплект Govee Wi-Fi через LAN API", + "device.type.group": "Группа", + "device.type.group.desc": "Объединение нескольких устройств в одно виртуальное", + "device.type.hint": "Выберите тип LED контроллера", + "device.type.hue": "Philips Hue", + "device.type.hue.desc": "Hue Entertainment API streaming", + "device.type.lifx": "LIFX", + "device.type.lifx.desc": "Умная лампа / лента LIFX по LAN", + "device.type.mock": "Mock", + "device.type.mock.desc": "Виртуальное устройство для тестов", + "device.type.mqtt": "MQTT", + "device.type.mqtt.desc": "Отправка LED данных через MQTT брокер", + "device.type.nanoleaf": "Nanoleaf", + "device.type.nanoleaf.desc": "Nanoleaf Light Panels / Canvas / Shapes / Lines / Elements (HTTP REST, требует сопряжения)", + "device.type.opc": "OPC", + "device.type.opc.desc": "Open Pixel Control (Fadecandy, xLights, любительские драйверы)", + "device.type.openrgb": "OpenRGB", + "device.type.openrgb.desc": "Управление RGB через OpenRGB", + "device.type.spi": "SPI Direct", + "device.type.spi.desc": "Raspberry Pi GPIO/SPI LED strips", + "device.type.usbhid": "USB HID", + "device.type.usbhid.desc": "USB RGB peripherals (keyboards, mice)", + "device.type.wiz": "WiZ", + "device.type.wiz.desc": "Лампа WiZ Connected (Philips) по UDP", + "device.type.wled": "WLED", + "device.type.wled.desc": "WiFi LED контроллер по HTTP/UDP", + "device.type.ws": "WebSocket", + "device.type.ws.desc": "Стриминг LED данных через WebSocket", + "device.type.yeelight": "Yeelight", + "device.type.yeelight.desc": "Умная лампа / лента Xiaomi по LAN (один цвет, усреднённый по ленте)", + "device.url": "URL:", + "device.url.hint": "IP адрес или имя хоста устройства (напр. http://192.168.1.100)", + "device.url.placeholder": "http://192.168.1.100", + "device.usbhid.url": "VID:PID:", + "device.usbhid.url.hint": "USB Vendor:Product ID in hex (e.g. 1532:0084)", + "device.wiz.url": "IP-адрес:", + "device.wiz.url.hint": "IP-адрес лампы WiZ в локальной сети. UDP-порт 38899 — по умолчанию.", + "device.wiz.url.placeholder": "192.168.1.50", + "device.wiz_min_interval": "Мин. интервал обновления:", + "device.wiz_min_interval.hint": "Локальный лимит частоты команд (мс). UDP fire-and-forget справляется с быстрыми обновлениями; по умолчанию 50 мс ≈ 20 Гц.", + "device.ws_url": "URL подключения:", + "device.ws_url.hint": "WebSocket URL для подключения клиентов и получения LED данных", + "device.yeelight.url": "IP-адрес:", + "device.yeelight.url.hint": "IP-адрес лампы Yeelight в локальной сети. TCP-порт 55443 фиксирован протоколом.", + "device.yeelight.url.placeholder": "192.168.1.50", + "device.yeelight_min_interval": "Мин. интервал обновления:", + "device.yeelight_min_interval.hint": "Локальный лимит частоты команд (мс). По умолчанию 500 мс держит лампу под ограничением ~1 команда/сек; меньшие значения могут вызвать троттлинг.", + "device_discovery.added": "Устройство успешно добавлено", + "device_discovery.error.add_failed": "Не удалось добавить устройство", + "device_discovery.error.fill_all_fields": "Пожалуйста, заполните все поля", + "devices.add": "Добавить Новое Устройство", + "devices.failed": "Не удалось загрузить устройства", + "devices.loading": "Загрузка устройств...", + "devices.none": "Устройства не настроены", + "devices.title": "Устройства", + "devices.wled_config": "Конфигурация WLED:", + "devices.wled_link": "официальное приложение WLED", + "devices.wled_note": "Настройте ваше WLED устройство (эффекты, сегменты, порядок цветов, ограничения питания и т.д.) используя", + "devices.wled_note_or": "или встроенный", + "devices.wled_note_webui": "(откройте IP устройства в браузере).", + "devices.wled_note2": "Этот контроллер отправляет данные о цвете пикселей и управляет яркостью для каждого устройства.", + "devices.wled_webui_link": "веб-интерфейс WLED", + "displays.badge.primary": "Основной", + "displays.badge.secondary": "Вторичный", + "displays.failed": "Не удалось загрузить дисплеи", + "displays.index": "Индекс Дисплея:", + "displays.information": "Информация о Дисплеях", + "displays.layout": "Дисплеи", + "displays.legend.primary": "Основной Дисплей", + "displays.legend.secondary": "Вторичный Дисплей", + "displays.loading": "Загрузка дисплеев...", + "displays.none": "Нет доступных дисплеев", + "displays.picker.adb_connect": "Подключить ADB устройство", + "displays.picker.adb_connect.button": "Подключить", + "displays.picker.adb_connect.error": "Не удалось подключить устройство", + "displays.picker.adb_connect.placeholder": "IP адрес (напр. 192.168.2.201)", + "displays.picker.adb_connect.success": "Устройство подключено", + "displays.picker.adb_disconnect": "Отключить", + "displays.picker.click_to_select": "Нажмите, чтобы выбрать этот дисплей", + "displays.picker.eyebrow.channel": "Дисплей · Карта", + "displays.picker.eyebrow.channel.device": "Устройство · Список", + "displays.picker.eyebrow.label": "Источник", + "displays.picker.foot.dismiss": "закрыть", + "displays.picker.foot.select": "выбрать монитор", + "displays.picker.foot.select.device": "выбрать устройство", + "displays.picker.no_android": "Android устройства не найдены. Подключите по USB или введите IP выше.", + "displays.picker.select": "Выберите дисплей...", + "displays.picker.title": "Выберите Дисплей", + "displays.picker.title.accent": "Дисплей", + "displays.picker.title.lead": "Выбрать", + "displays.position": "Позиция:", + "displays.refresh_rate": "Частота Обновления:", + "displays.resolution": "Разрешение:", + "displays.title": "Доступные Дисплеи", + "donation.about_author": "Создатель —", + "donation.about_donate": "Поддержать разработку", + "donation.about_license": "Лицензия MIT", + "donation.about_opensource": "LedGrab — программа с открытым исходным кодом, бесплатная для использования и модификации.", + "donation.about_title": "О LedGrab", + "donation.dismiss": "Больше не показывать", + "donation.later": "Напомнить позже", + "donation.message": "LedGrab — бесплатный проект с открытым кодом. Если он вам полезен, поддержите разработку.", + "donation.support": "Поддержать проект", + "donation.view_source": "Исходный код", + "error.network": "Сетевая ошибка", + "error.unauthorized": "Не авторизован - пожалуйста, войдите", + "error.unknown": "Произошла ошибка", + "filters.add": "Добавить фильтр", + "filters.auto_crop": "Авто Обрезка", + "filters.auto_crop.desc": "Удаление чёрных полос из леттербокса", + "filters.brightness": "Яркость", + "filters.brightness.desc": "Регулировка общей яркости изображения", + "filters.color_correction": "Цветокоррекция", + "filters.color_correction.desc": "Баланс белого и цветовая температура", + "filters.css_filter_template": "Шаблон Фильтра Полос", + "filters.css_filter_template.desc": "Встроить другой шаблон обработки полос", + "filters.downscaler": "Уменьшение", + "filters.downscaler.desc": "Снижение разрешения для быстрой обработки", + "filters.drag_to_reorder": "Перетащите для изменения порядка", + "filters.empty": "Фильтры не добавлены. Используйте селектор ниже для добавления.", + "filters.filter_template": "Шаблон фильтров", + "filters.filter_template.desc": "Встроить другой шаблон обработки", + "filters.flip": "Отражение", + "filters.flip.desc": "Зеркальное отражение по горизонтали или вертикали", + "filters.frame_interpolation": "Интерполяция кадров", + "filters.frame_interpolation.desc": "Сглаживание между кадрами", + "filters.gamma": "Гамма", + "filters.gamma.desc": "Нелинейная коррекция кривой яркости", + "filters.noise_gate": "Шумоподавление", + "filters.noise_gate.desc": "Подавление малых изменений цвета ниже порога", + "filters.palette_quantization": "Квантизация палитры", + "filters.palette_quantization.desc": "Сокращение цветов до ограниченной палитры", + "filters.pixelate": "Пикселизация", + "filters.pixelate.desc": "Мозаичное усреднение блоков", + "filters.remove": "Удалить", + "filters.reverse": "Реверс", + "filters.reverse.desc": "Изменить порядок светодиодов на обратный", + "filters.saturation": "Насыщенность", + "filters.saturation.desc": "Усиление или снижение интенсивности цвета", + "filters.select_type": "Выберите тип фильтра...", + "game_integration.adapter": "Адаптер", + "game_integration.adapter_config": "Конфигурация адаптера", + "game_integration.adapter_type": "Игра / Адаптер:", + "game_integration.adapter_type.hint": "Выберите тип игры или адаптера", + "game_integration.add": "Добавить игровую интеграцию", + "game_integration.auto_setup": "Автонастройка", + "game_integration.auto_setup.failed": "Автонастройка не удалась", + "game_integration.auto_setup.game_not_found": "Установка игры не найдена", + "game_integration.auto_setup.not_supported": "Этот адаптер не поддерживает автонастройку", + "game_integration.auto_setup.save_first": "Сначала сохраните интеграцию перед запуском автонастройки", + "game_integration.auto_setup.success": "Файл конфигурации успешно записан", + "game_integration.auto_setup.token_generated": "Токен авторизации был сгенерирован автоматически", + "game_integration.confirm_delete": "Удалить эту игровую интеграцию?", + "game_integration.created": "Игровая интеграция создана", + "game_integration.deleted": "Игровая интеграция удалена", + "game_integration.description": "Описание:", + "game_integration.description.hint": "Необязательное описание назначения интеграции", + "game_integration.edit": "Редактировать игровую интеграцию", + "game_integration.enabled": "Включено", + "game_integration.enabled.hint": "Отключённые интеграции перестают опрашивать и генерировать события", + "game_integration.error.delete_failed": "Не удалось удалить игровую интеграцию", + "game_integration.error.name_required": "Требуется имя", + "game_integration.error.save_failed": "Не удалось сохранить игровую интеграцию", + "game_integration.error.save_first": "Сначала сохраните интеграцию для проверки соединения", + "game_integration.event_mappings": "Привязка событий", + "game_integration.event_mappings.hint": "Привяжите игровые события к LED-эффектам. Каждый тип события может вызывать свой визуальный эффект.", + "game_integration.events.monitor": "Монитор событий", + "game_integration.events.title": "События в реальном времени", + "game_integration.events.waiting": "Ожидание событий...", + "game_integration.mapping.add": "+ Добавить привязку", + "game_integration.mapping.color": "Цвет", + "game_integration.mapping.duration": "Длительность (мс)", + "game_integration.mapping.effect_type": "Эффект", + "game_integration.mapping.event_type": "Событие", + "game_integration.mapping.intensity": "Интенсивность", + "game_integration.mapping.priority": "Приоритет", + "game_integration.mapping.select_preset": "Загрузить пресет...", + "game_integration.mappings": "Привязки", + "game_integration.name": "Имя:", + "game_integration.name.hint": "Описательное имя для этой игровой интеграции", + "game_integration.no_config": "Конфигурация для этого адаптера не требуется.", + "game_integration.preset.fps_combat": "FPS Бой", + "game_integration.preset.moba_health": "MOBA Здоровье", + "game_integration.preset.select": "Загрузить пресет...", + "game_integration.section_title": "Игровые интеграции", + "game_integration.setup_instructions": "Инструкции по настройке", + "game_integration.setup_instructions.hint": "Следуйте этим шагам для настройки отправки данных из игры", + "game_integration.status": "Статус", + "game_integration.status.active": "Активна", + "game_integration.status.inactive": "Неактивна", + "game_integration.test.button": "Тестировать соединение", + "game_integration.test.error": "Ошибка соединения", + "game_integration.test.success": "Соединение успешно! Получены события.", + "game_integration.test.timeout": "События не получены за отведённое время.", + "game_integration.test.waiting": "Ожидание событий от игры...", + "game_integration.updated": "Игровая интеграция обновлена", + "gradient.error.delete_failed": "Не удалось удалить градиент", + "gradient.error.save_failed": "Не удалось сохранить градиент", + "graph.action.connect": "Соединить", + "graph.action.disconnect": "Отсоединить", + "graph.action.move": "Переместить узел", + "graph.action.rewire": "Переподключить слот", + "graph.add_entity": "Добавить сущность", + "graph.bulk_delete_confirm": "Удалить {count} выбранных сущностей?", + "graph.choose_connection": "Выберите соединение", + "graph.color_picker": "Цвет узла", + "graph.connection_failed": "Не удалось обновить соединение", + "graph.connection_removed": "Соединение удалено", + "graph.connection_updated": "Соединение обновлено", + "graph.create_and_connect": "Создать и соединить…", + "graph.delete_with_dependents_confirm": "Этот объект используется {count} другими: {names}. Удалить и разорвать эти связи?", + "graph.disconnect": "Отключить", + "graph.disconnect_failed": "Не удалось отключить", + "graph.duplicate": "Дублировать выбранное", + "graph.duplicate_done": "Продублировано источников: {count}", + "graph.duplicate_done_warn": "Продублировано источников: {count} — часть ссылок не удалось перепривязать", + "graph.duplicate_failed": "Не удалось дублировать выбранное", + "graph.duplicate_none": "Выберите один или несколько узлов для дублирования", + "graph.duplicate_none_eligible": "В выборе нечего дублировать (только источники значений и цветовых лент)", + "graph.empty": "Ещё нет сущностей", + "graph.empty.hint": "Создайте устройства, источники и цели, чтобы увидеть их здесь.", + "graph.export": "Экспорт графа (JSON)", + "graph.export_done": "Граф экспортирован", + "graph.export_failed": "Не удалось экспортировать граф", + "graph.filter": "Фильтр узлов", + "graph.filter_clear": "Очистить фильтр", + "graph.filter_group.audio": "Аудио", + "graph.filter_group.capture": "Захват", + "graph.filter_group.other": "Другое", + "graph.filter_group.strip": "Цвет. полосы", + "graph.filter_group.targets": "Цели", + "graph.filter_placeholder": "Фильтр по имени...", + "graph.filter_running": "Запущен", + "graph.filter_stopped": "Остановлен", + "graph.filter_types": "Типы", + "graph.fit_all": "Показать все узлы", + "graph.fullscreen": "Полноэкранный режим", + "graph.help.add": "Добавить сущность", + "graph.help.click": "Клик", + "graph.help.click_desc": "Выбрать узел", + "graph.help.dblclick": "Двойной клик", + "graph.help.dblclick_desc": "Приблизить к узлу", + "graph.help.delete": "Удалить / Отсоединить", + "graph.help.deselect": "Снять выбор", + "graph.help.drag_node": "Перетащить узел", + "graph.help.drag_node_desc": "Переместить", + "graph.help.drag_port": "Перетащить порт", + "graph.help.drag_port_desc": "Соединить сущности", + "graph.help.filter": "Фильтр", + "graph.help.fullscreen": "Полный экран", + "graph.help.navigate": "Навигация по узлам", + "graph.help.redo": "Повторить", + "graph.help.right_click": "ПКМ по связи", + "graph.help.right_click_desc": "Отсоединить связь", + "graph.help.search": "Поиск", + "graph.help.select_all": "Выбрать все", + "graph.help.shift_click": "Shift+Клик", + "graph.help.shift_click_desc": "Множественный выбор", + "graph.help.shift_drag": "Shift+Перетащить", + "graph.help.shift_drag_desc": "Выбор рамкой", + "graph.help.shortcuts": "Горячие клавиши", + "graph.help.undo": "Отменить", + "graph.help_title": "Горячие клавиши", + "graph.issue.broken_ref": "Битая ссылка: {field}", + "graph.issue.cycle": "Входит в цикл зависимостей", + "graph.issues": "Проблемы", + "graph.issues_none": "Проблем не найдено", + "graph.legend": "Легенда", + "graph.minimap": "Миникарта", + "graph.no_compatible_connection": "Нет совместимого соединения между этими объектами", + "graph.nothing_to_redo": "Нечего повторять", + "graph.nothing_to_undo": "Нечего отменять", + "graph.redone": "Повторено", + "graph.relayout": "Перестроить", + "graph.relayout_confirm": "Сбросить все ручные позиции узлов и перестроить граф?", + "graph.replace_connection_confirm": "Заменить существующее соединение?", + "graph.rewire": "Переподключить…", + "graph.rewire_choose_source": "Выберите новый источник", + "graph.search": "Поиск узлов", + "graph.search_placeholder": "Поиск сущностей...", + "graph.title": "Граф", + "graph.tooltip.errors": "Ошибки", + "graph.tooltip.fps": "FPS", + "graph.tooltip.uptime": "Время работы", + "graph.undone": "Отменено", + "graph.zoom_in": "Приблизить", + "graph.zoom_out": "Отдалить", + "ha_light.add": "Добавить HA-цель освещения", + "ha_light.button.turn_off": "Выключить лампы", + "ha_light.color_source": "Источник цвета:", + "ha_light.color_source.color_vs": "Источник значения цвета", + "ha_light.color_source.css": "Полоса цвета", + "ha_light.color_source.hint": "Выберите Источник полосы цвета (диапазоны LED для каждой лампы) или Источник значения цвета (один цвет на все лампы).", + "ha_light.color_tolerance": "Допуск цвета:", + "ha_light.color_tolerance.hint": "Пропускать обновление цвета, если разница RGB ниже этого порога. Снижает нагрузку на HA для статичных сцен.", + "ha_light.connection_metric": "Подключение Home Assistant", + "ha_light.connection_tooltip": "Подключение HA", + "ha_light.created": "HA-цель освещения создана", + "ha_light.css_source": "Источник полосы цвета:", + "ha_light.description": "Описание (опционально):", + "ha_light.edit": "Редактировать HA-цель освещения", + "ha_light.error.color_source_required": "Источник значения цвета обязателен в режиме одного цвета", + "ha_light.error.ha_source_required": "Требуется HA-подключение", + "ha_light.error.name_required": "Требуется имя", + "ha_light.ha_source": "HA-подключение:", + "ha_light.lights.few": "{count} лампы", + "ha_light.lights.many": "{count} ламп", + "ha_light.lights.one": "{count} лампа", + "ha_light.lights.other": "{count} ламп", + "ha_light.mapping.brightness": "Множитель яркости:", + "ha_light.mapping.entity_id": "ID сущности:", + "ha_light.mapping.led_end": "Конец LED (-1=последний):", + "ha_light.mapping.led_start": "Начало LED:", + "ha_light.mapping.search_entity": "Поиск сущностей-ламп...", + "ha_light.mapping.select_entity": "Выберите сущность-лампу...", + "ha_light.mapping.unassigned": "— Сущность не выбрана", + "ha_light.mappings": "Привязки ламп:", + "ha_light.mappings.add": "Добавить привязку", + "ha_light.mappings.color_vs_hint": "Все указанные лампы получат один и тот же цвет от выбранного Источника значения цвета.", + "ha_light.mappings.hint": "Сопоставьте диапазоны LED с сущностями ламп HA. Каждая привязка усредняет LED-сегмент до одного цвета.", + "ha_light.min_brightness_threshold": "Мин. порог яркости:", + "ha_light.min_brightness_threshold.hint": "Эффективная яркость ниже этого значения выключает свет полностью (0 = отключено).", + "ha_light.name": "Имя:", + "ha_light.name.placeholder": "Лампы гостиной", + "ha_light.section.targets": "Цели освещения", + "ha_light.section.title": "Home Assistant", + "ha_light.stop_action": "При остановке:", + "ha_light.stop_action.hint": "Что делать с привязанными лампами, когда цель прекращает стриминг.", + "ha_light.stop_action.none": "Ничего", + "ha_light.stop_action.none.desc": "Оставить лампы как есть", + "ha_light.stop_action.restore": "Восстановить", + "ha_light.stop_action.restore.desc": "Вернуть состояние на момент запуска", + "ha_light.stop_action.turn_off": "Выключить", + "ha_light.stop_action.turn_off.desc": "Выключить все привязанные лампы", + "ha_light.transition": "Переход:", + "ha_light.transition.hint": "Длительность плавного перехода между цветами (параметр HA transition).", + "ha_light.turn_off.failed": "Не удалось выключить лампы", + "ha_light.turn_off.success": "Лампы выключены", + "ha_light.update_rate": "Частота обновления:", + "ha_light.update_rate.hint": "Как часто отправлять обновления цвета лампам HA (0.5-5.0 Гц). Меньшие значения безопаснее для производительности HA.", + "ha_light.updated": "HA-цель освещения обновлена", + "ha_source.use_ssl.hint": "Включите для HTTPS / wss соединений с Home Assistant", + "http_endpoint.add": "Добавить HTTP-эндпоинт", + "http_endpoint.auth.set": "Авторизация", + "http_endpoint.auth_token": "Токен авторизации (необязательно):", + "http_endpoint.auth_token.edit_hint": "Оставьте пустым, чтобы сохранить текущий токен", + "http_endpoint.auth_token.hint": "Отправляется как \u0027Authorization: Bearer \u003ctoken\u003e\u0027. Добавьте свой Authorization-заголовок, чтобы переопределить.", + "http_endpoint.auth_token.reveal": "Показать / скрыть токен", + "http_endpoint.created": "HTTP-эндпоинт создан", + "http_endpoint.delete.confirm": "Удалить этот HTTP-эндпоинт? Источники-значений, ссылающиеся на него, потребуется перенастроить.", + "http_endpoint.deleted": "HTTP-эндпоинт удалён", + "http_endpoint.description": "Описание (необязательно):", + "http_endpoint.edit": "Изменить HTTP-эндпоинт", + "http_endpoint.error.load": "Не удалось загрузить HTTP-эндпоинт", + "http_endpoint.error.name_required": "Имя обязательно", + "http_endpoint.error.timeout_invalid": "Таймаут должен быть положительным числом", + "http_endpoint.error.url_required": "URL обязателен", + "http_endpoint.group.title": "HTTP-эндпоинты", + "http_endpoint.headers": "Заголовки:", + "http_endpoint.headers.add": "Добавить заголовок", + "http_endpoint.headers.count": "{n} заголовков", + "http_endpoint.headers.empty": "Нет дополнительных заголовков — будут отправлены значения по умолчанию.", + "http_endpoint.headers.hint": "Дополнительные заголовки запроса (например, X-API-Key, Accept).", + "http_endpoint.headers.name_placeholder": "Имя заголовка", + "http_endpoint.headers.value_placeholder": "Значение", + "http_endpoint.method": "Метод:", + "http_endpoint.method.get.desc": "Получить тело ответа.", + "http_endpoint.method.head.desc": "Только код статуса — без тела. Подходит для проверки доступности.", + "http_endpoint.name": "Имя:", + "http_endpoint.name.hint": "Описательное имя для этого эндпоинта", + "http_endpoint.name.placeholder": "Plex now-playing", + "http_endpoint.section.headers": "Заголовки", + "http_endpoint.section.request": "Запрос", + "http_endpoint.test": "Тестовый запрос", + "http_endpoint.test.body.json": "JSON-тело", + "http_endpoint.test.body.text": "Тело ответа", + "http_endpoint.test.failed": "Ошибка", + "http_endpoint.test.pending": "Проверка…", + "http_endpoint.test.success": "Успех", + "http_endpoint.timeout": "Таймаут (с):", + "http_endpoint.timeout.hint": "Максимальное время ожидания одного запроса (в секундах).", + "http_endpoint.updated": "HTTP-эндпоинт обновлён", + "http_endpoint.url": "URL:", + "http_endpoint.url.hint": "Полный http(s)-URL для опроса. Локальные адреса разрешены.", + "integrations.title": "Интеграции", + "locale.change": "Изменить язык", + "modal.discard_changes": "У вас есть несохранённые изменения. Отменить их?", + "notifications.bulk.body": "Одновременно произошло {count} событий устройств", + "notifications.bulk.title": "Изменилось несколько устройств", + "notifications.device_discovered.body": "{device} появилось в сети", + "notifications.device_discovered.title": "Новое устройство", + "notifications.device_lost.body": "{device} исчезло", + "notifications.device_lost.title": "Устройство пропало", + "notifications.device_offline.body": "{device} перестало отвечать", + "notifications.device_offline.title": "Устройство недоступно", + "notifications.device_online.body": "{device} снова в сети", + "notifications.device_online.title": "Устройство в сети", + "notifications.test.body": "Уведомления работают корректно.", + "notifications.test.title": "Тестовое уведомление LedGrab", + "notifications.unknown_device": "Неизвестное устройство", + "overlay.button.hide": "Скрыть визуализацию наложения", + "overlay.button.show": "Показать визуализацию наложения", + "overlay.error.start": "Не удалось запустить наложение", + "overlay.error.stop": "Не удалось остановить наложение", + "overlay.started": "Визуализация наложения запущена", + "overlay.stopped": "Визуализация наложения остановлена", + "overlay.toggle": "Переключить наложение на экран", + "pairing.cancel": "Отмена", + "pairing.close": "Закрыть", + "pairing.failed": "Сопряжение не удалось: {detail}", + "pairing.failed_prefix": "Сопряжение не удалось:", + "pairing.instructions.default": "Нажмите и удерживайте кнопку сопряжения на устройстве, затем нажмите «Начать». Устройства обычно открывают окно сопряжения на 30 секунд — действуйте быстро.", + "pairing.not_ready": "Устройство не ответило. Нажмите кнопку сопряжения на устройстве и попробуйте снова.", + "pairing.pairing": "Сопряжение…", + "pairing.retry": "Повторить", + "pairing.start": "Начать сопряжение", + "pairing.success": "Успешно сопряжено", + "pairing.title": "Сопряжение устройства", + "palette.search": "Поиск…", + "patch.checking": "ПРОВЕРКА", + "patch.disconnected": "ОТКЛЮЧЕНО", + "patch.not_configured": "НЕ НАСТРОЕНО", + "patch.offline": "ОФЛАЙН", + "patch.online": "ОНЛАЙН", + "patch.patched": "ПОДКЛЮЧЕНО", + "patch.paused": "ПАУЗА", + "patch.pipeline": "КОНВЕЙЕР", + "patch.polling": "ОПРОС", + "patch.preset": "ПРЕСЕТ", + "patch.ready": "ГОТОВ", + "patch.source": "ИСТОЧНИК", + "patch.standby": "ОЖИДАНИЕ", + "patch.streaming": "ТРАНСЛЯЦИЯ", + "patch.strip": "ПОЛОСА", + "patch.template": "ШАБЛОН", + "patch.ticking": "ИДЁТ", + "patch.unreachable": "НЕДОСТУПЕН", + "patch.value": "ЗНАЧЕНИЕ", + "pattern.add": "Добавить Шаблон Паттерна", + "pattern.capture_bg": "Захватить Фон", + "pattern.created": "Шаблон паттерна успешно создан", + "pattern.delete.confirm": "Вы уверены, что хотите удалить этот шаблон паттерна?", + "pattern.delete.referenced": "Невозможно удалить: шаблон используется целью", + "pattern.delete_selected": "Удалить Выбранный", + "pattern.deleted": "Шаблон паттерна успешно удалён", + "pattern.description.hint": "Необязательные заметки о назначении этого паттерна", + "pattern.description_label": "Описание (необязательно):", + "pattern.description_placeholder": "Опишите этот паттерн...", + "pattern.edit": "Редактировать Шаблон Паттерна", + "pattern.error.capture_bg_failed": "Не удалось захватить фон", + "pattern.error.clone_failed": "Не удалось клонировать шаблон узоров", + "pattern.error.delete_failed": "Не удалось удалить шаблон узоров", + "pattern.error.editor_open_failed": "Не удалось открыть редактор шаблона узоров", + "pattern.error.required": "Пожалуйста, заполните все обязательные поля", + "pattern.error.save_failed": "Не удалось сохранить шаблон узоров", + "pattern.name": "Имя Шаблона:", + "pattern.name.hint": "Описательное имя для этой раскладки прямоугольников", + "pattern.name.placeholder": "Мой Шаблон Паттерна", + "pattern.rect.add": "Добавить Прямоугольник", + "pattern.rect.empty": "Прямоугольники не определены. Добавьте хотя бы один.", + "pattern.rect.height": "В", + "pattern.rect.name": "Имя", + "pattern.rect.remove": "Удалить", + "pattern.rect.width": "Ш", + "pattern.rect.x": "X", + "pattern.rect.y": "Y", + "pattern.rectangles": "Прямоугольники", + "pattern.rectangles.hint": "Точная настройка позиций и размеров прямоугольников в координатах (0.0 до 1.0)", + "pattern.source_for_bg": "Источник для Фона:", + "pattern.source_for_bg.none": "-- Выберите источник --", + "pattern.updated": "Шаблон паттерна успешно обновлён", + "pattern.visual_editor": "Визуальный Редактор", + "pattern.visual_editor.hint": "Нажмите кнопки + чтобы добавить прямоугольники. Тяните края для изменения размера, тяните внутри для перемещения.", + "perf.all_online": "все онлайн", + "perf.captures_count.few": "{count} источника", + "perf.captures_count.many": "{count} источников", + "perf.captures_count.one": "{count} источник", + "perf.idle": "простой", + "perf.max_ms": "макс {ms}мс", + "perf.no_captures": "нет источников", + "perf.no_devices": "нет устройств", + "perf.offline": "офлайн", + "perf.offline_count": "{count} офлайн", + "perf.online": "онлайн", + "perf.online_count": "{count} онлайн", + "perf.ratio_of_requested": "{percent}% от запрошенного · {captures}", + "perf.skipped_per_sec": "{rate} пропущ/с", + "perf.targets_count.few": "{count} цели", + "perf.targets_count.many": "{count} целей", + "perf.targets_count.one": "{count} цель", + "perf.tip.ago": "−{seconds}с", + "perf.tip.now": "сейчас", + "perf.total_bytes": "всего {bytes}", + "perf.total_count": "{count} всего", + "picture_source.type.video": "Видео", + "picture_source.type.video.desc": "Потоковые кадры из загруженного видео", + "picture_source.video.end_time": "Время окончания (с):", + "picture_source.video.loop": "Зацикливание:", + "picture_source.video.resolution_limit": "Макс. ширина (px):", + "picture_source.video.resolution_limit.hint": "Уменьшение видео при декодировании для производительности", + "picture_source.video.speed": "Скорость воспроизведения:", + "picture_source.video.start_time": "Время начала (с):", + "playlists.action.start": "Запустить", + "playlists.action.stop": "Остановить", + "playlists.add": "Новый плейлист", + "playlists.chip.loop": "Цикл", + "playlists.chip.shuffle": "Перемешать", + "playlists.created": "Плейлист создан", + "playlists.delete_confirm": "Удалить плейлист «{name}»?", + "playlists.deleted": "Плейлист удалён", + "playlists.description": "Описание:", + "playlists.description.hint": "Необязательное описание плейлиста", + "playlists.edit": "Изменить плейлист", + "playlists.error.delete_failed": "Не удалось удалить плейлист", + "playlists.error.name_required": "Требуется название", + "playlists.error.no_presets": "Сначала создайте пресет сцены", + "playlists.error.save_failed": "Не удалось сохранить плейлист", + "playlists.error.start_failed": "Не удалось запустить плейлист", + "playlists.error.stop_failed": "Не удалось остановить плейлист", + "playlists.item.duration": "Секунды", + "playlists.item.missing": "Отсутствует", + "playlists.item.move_down": "Вниз", + "playlists.item.move_up": "Вверх", + "playlists.items.empty": "Сцен пока нет — добавьте ниже", + "playlists.items.search_placeholder": "Поиск сцен...", + "playlists.loop": "Зацикливание:", + "playlists.loop.hint": "Начинать заново с первой сцены после последней; если выключено — проиграть один раз и остановиться", + "playlists.name": "Название:", + "playlists.name.placeholder": "Мой плейлист", + "playlists.scene_many": "сцен", + "playlists.scene_one": "сцена", + "playlists.scenes": "Сцены:", + "playlists.scenes.add": "Добавить сцену", + "playlists.scenes.hint": "Пресеты сцен, которые перебирает плейлист, каждая удерживается своё время", + "playlists.scenes_count": "сцен", + "playlists.section.playback": "Воспроизведение", + "playlists.shuffle": "Перемешивание:", + "playlists.shuffle.hint": "Случайный порядок сцен в начале каждого цикла", + "playlists.start": "Запустить плейлист", + "playlists.started": "Плейлист запущен", + "playlists.status.playing": "Воспроизводится", + "playlists.status.stopped": "Остановлен", + "playlists.stop": "Остановить плейлист", + "playlists.stopped": "Плейлист остановлен", + "playlists.title": "Плейлисты", + "playlists.updated": "Плейлист обновлён", + "postprocessing.add": "Добавить Шаблон Фильтра", + "postprocessing.config.show": "Показать настройки", + "postprocessing.created": "Шаблон успешно создан", + "postprocessing.delete.confirm": "Вы уверены, что хотите удалить этот шаблон фильтра?", + "postprocessing.deleted": "Шаблон успешно удалён", + "postprocessing.description": "Шаблоны обработки определяют фильтры изображений и цветокоррекцию. Назначайте их обработанным источникам для единообразной постобработки на всех устройствах.", + "postprocessing.description_label": "Описание (необязательно):", + "postprocessing.description_placeholder": "Опишите этот шаблон...", + "postprocessing.edit": "Редактировать Шаблон Фильтра", + "postprocessing.error.delete": "Не удалось удалить шаблон фильтра", + "postprocessing.error.load": "Не удалось загрузить шаблоны фильтров", + "postprocessing.error.required": "Пожалуйста, заполните все обязательные поля", + "postprocessing.name": "Имя Шаблона:", + "postprocessing.name.placeholder": "Мой Шаблон Фильтра", + "postprocessing.test.error.failed": "Тест шаблона фильтра не удался", + "postprocessing.test.error.no_stream": "Пожалуйста, выберите источник", + "postprocessing.test.running": "Тестирование шаблона фильтра...", + "postprocessing.test.source_stream": "Источник:", + "postprocessing.test.title": "Тест шаблона фильтра", + "postprocessing.title": "Шаблоны Фильтров", + "postprocessing.updated": "Шаблон успешно обновлён", + "scene_preset.activated": "Пресет активирован", + "scene_preset.used_by": "Используется в %d автоматизации(ях)", + "scenes.action.activate": "Активировать", + "scenes.action.recapture": "Перезахват", + "scenes.activate": "Активировать сцену", + "scenes.activated": "Сцена активирована", + "scenes.activated_partial": "Сцена активирована частично", + "scenes.add": "Захватить сцену", + "scenes.capture": "Захват", + "scenes.captured": "Сцена захвачена", + "scenes.cloned": "Сцена клонирована", + "scenes.delete": "Удалить сцену", + "scenes.delete_confirm": "Удалить сцену \"{name}\"?", + "scenes.deleted": "Сцена удалена", + "scenes.description": "Описание:", + "scenes.description.hint": "Необязательное описание назначения этой сцены", + "scenes.edit": "Редактировать сцену", + "scenes.error.activate_failed": "Не удалось активировать сцену", + "scenes.error.clone_failed": "Не удалось клонировать сцену", + "scenes.error.delete_failed": "Не удалось удалить сцену", + "scenes.error.name_required": "Необходимо указать название", + "scenes.error.recapture_failed": "Не удалось перезахватить сцену", + "scenes.error.save_failed": "Не удалось сохранить сцену", + "scenes.errors": "ошибок", + "scenes.name": "Название:", + "scenes.name.hint": "Описательное имя для этого пресета сцены", + "scenes.name.placeholder": "Моя сцена", + "scenes.recapture": "Перезахватить текущее состояние", + "scenes.recapture_confirm": "Перезахватить текущее состояние в \"{name}\"?", + "scenes.recaptured": "Сцена перезахвачена", + "scenes.status.preset": "Пресет", + "scenes.targets": "Цели:", + "scenes.targets.add": "Добавить цель", + "scenes.targets.empty": "Цели не выбраны", + "scenes.targets.hint": "Выберите какие цели включить в снимок сцены", + "scenes.targets.search_placeholder": "Поиск целей...", + "scenes.targets_count": "целей", + "scenes.title": "Сцены", + "scenes.updated": "Сцена обновлена", + "search.action.activate": "Активировать", + "search.action.disable": "Отключить", + "search.action.enable": "Включить", + "search.action.start": "Запустить", + "search.action.stop": "Остановить", + "search.footer": "↑↓ навигация · Enter выбор · Esc закрыть", + "search.group.actions": "Действия", + "search.group.audio": "Аудиоисточники", + "search.group.automations": "Автоматизации", + "search.group.capture_templates": "Шаблоны захвата", + "search.group.cspt": "Шаблоны обработки полос", + "search.group.css": "Источники цветных лент", + "search.group.devices": "Устройства", + "search.group.kc_targets": "Цели Key Colors", + "search.group.pattern_templates": "Шаблоны паттернов", + "search.group.pp_templates": "Шаблоны постобработки", + "search.group.scenes": "Пресеты сцен", + "search.group.streams": "Потоки изображений", + "search.group.sync_clocks": "Синхронные часы", + "search.group.targets": "LED-цели", + "search.group.value": "Источники значений", + "search.loading": "Загрузка...", + "search.no_results": "Ничего не найдено", + "search.open": "Поиск (Ctrl+K)", + "search.placeholder": "Поиск... (Ctrl+K)", + "section.collapse_all": "Свернуть все секции", + "section.empty.assets": "Ресурсов пока нет. Нажмите +, чтобы загрузить.", + "section.empty.audio_processing_templates": "Пока нет шаблонов обработки звука. Нажмите +, чтобы создать.", + "section.empty.audio_sources": "Аудио-источников пока нет. Нажмите + для добавления.", + "section.empty.audio_templates": "Аудио-шаблонов пока нет. Нажмите + для добавления.", + "section.empty.automations": "Автоматизаций пока нет. Нажмите + для добавления.", + "section.empty.capture_templates": "Шаблонов захвата пока нет. Нажмите + для добавления.", + "section.empty.color_strips": "Цветных полос пока нет. Нажмите + для добавления.", + "section.empty.cspt": "Шаблонов обработки полос пока нет. Нажмите + для добавления.", + "section.empty.devices": "Устройств пока нет. Нажмите + для добавления.", + "section.empty.game_integrations": "Нет игровых интеграций. Нажмите +, чтобы создать.", + "section.empty.http_endpoints": "Пока нет HTTP-эндпоинтов. Нажмите +, чтобы добавить.", + "section.empty.kc_targets": "Целей ключевых цветов пока нет. Нажмите + для добавления.", + "section.empty.pattern_templates": "Шаблонов паттернов пока нет. Нажмите + для добавления.", + "section.empty.picture_sources": "Источников пока нет. Нажмите + для добавления.", + "section.empty.playlists": "Плейлистов пока нет. Нажмите + для добавления.", + "section.empty.pp_templates": "Шаблонов постобработки пока нет. Нажмите + для добавления.", + "section.empty.scenes": "Пресетов сцен пока нет. Нажмите + для добавления.", + "section.empty.sync_clocks": "Синхронных часов пока нет. Нажмите + для добавления.", + "section.empty.targets": "LED-целей пока нет. Нажмите + для добавления.", + "section.empty.value_sources": "Источников значений пока нет. Нажмите + для добавления.", + "section.empty.weather_sources": "Нет источников погоды. Нажмите + для добавления.", + "section.expand_all": "Развернуть все секции", + "section.filter.placeholder": "Фильтр...", + "section.filter.reset": "Очистить фильтр", + "server.healthy": "Сервер онлайн", + "server.offline": "Сервер офлайн", + "settings.activity_log.clear.auth_required": "Для очистки журнала требуется аутентификация", + "settings.activity_log.clear.button": "Очистить журнал", + "settings.activity_log.clear.confirm": "Очистить все записи журнала активности? Это действие нельзя отменить.", + "settings.activity_log.clear.error": "Не удалось очистить журнал активности", + "settings.activity_log.clear.hint": "Безвозвратно удалить все записи журнала. Это действие фиксируется - системная запись сохраняет, кто и когда выполнил очистку.", + "settings.activity_log.clear.label": "Очистить все записи", + "settings.activity_log.clear.success": "Журнал активности очищен", + "settings.activity_log.distinction_note": "Это постоянный журнал аудита - структурированные записи об изменениях объектов, событиях аутентификации и системных действиях. Он отличается от эфемерного просмотрщика отладочных логов (живой хвост журнала сервера, сбрасывается при отключении).", + "settings.activity_log.enabled.hint": "При отключении новые записи аудита не создаются. Существующие записи сохраняются.", + "settings.activity_log.enabled.label": "Включить запись активности", + "settings.activity_log.error.max_days_range": "Макс. возраст должен быть от 0 до 3650 дней", + "settings.activity_log.error.max_entries_range": "Макс. записей должно быть от 0 до 10 000 000", + "settings.activity_log.export.hint": "Скачать полный журнал активности в виде файла.", + "settings.activity_log.export.view_tab_hint": "Для экспорта с фильтрами используйте", + "settings.activity_log.export_csv.button": "CSV", + "settings.activity_log.export_csv.label": "Экспорт в CSV", + "settings.activity_log.export_json.button": "JSON", + "settings.activity_log.export_json.label": "Экспорт в JSON", + "settings.activity_log.max_days.hint": "Записи старше указанного числа дней удаляются автоматически. Укажите 0, чтобы отключить удаление по возрасту.", + "settings.activity_log.max_days.label": "Макс. возраст (дни)", + "settings.activity_log.max_entries.hint": "Максимальное количество хранимых записей. При достижении лимита самые старые записи удаляются. Укажите 0 для снятия ограничения.", + "settings.activity_log.max_entries.label": "Макс. записей", + "settings.activity_log.open_activity_tab": "вкладка «Активность»", + "settings.activity_log.open_log_viewer": "просмотрщик отладочных логов", + "settings.activity_log.save": "Сохранить настройки", + "settings.activity_log.save_error": "Не удалось сохранить настройки", + "settings.activity_log.saved": "Настройки журнала активности сохранены", + "settings.activity_log.section.clear": "Очистить журнал", + "settings.activity_log.section.export": "Экспорт", + "settings.activity_log.section.retention": "Хранение", + "settings.api_keys.empty": "API-ключи не настроены", + "settings.api_keys.hint": "API-ключи определяются в конфигурационном файле сервера (config.yaml). Отредактируйте файл и перезапустите сервер для применения изменений.", + "settings.api_keys.label": "API-ключи", + "settings.api_keys.load_error": "Не удалось загрузить API-ключи", + "settings.api_keys.meta.many": "ключей", + "settings.api_keys.meta.one": "ключ", + "settings.api_keys.read_only": "Только чтение", + "settings.auto_backup.backup_created": "Бэкап создан", + "settings.auto_backup.backup_error": "Ошибка создания бэкапа", + "settings.auto_backup.backup_now": "Создать бэкап", + "settings.auto_backup.enable": "Включить авто-бэкап", + "settings.auto_backup.hint": "Автоматическое создание периодических резервных копий конфигурации. Старые копии удаляются при превышении максимального количества.", + "settings.auto_backup.interval_label": "Интервал", + "settings.auto_backup.label": "Авто-бэкап", + "settings.auto_backup.last_backup": "Последний бэкап", + "settings.auto_backup.max_label": "Макс. копий", + "settings.auto_backup.never": "Никогда", + "settings.auto_backup.pill.running": "АКТИВНО", + "settings.auto_backup.save": "Сохранить настройки", + "settings.auto_backup.save_error": "Не удалось сохранить настройки авто-бэкапа", + "settings.auto_backup.saved": "Настройки авто-бэкапа сохранены", + "settings.auto_shutdown": "Авто-восстановление:", + "settings.auto_shutdown.hint": "Восстанавливать устройство в режим ожидания при остановке целей или сервера", + "settings.backup.button": "Скачать", + "settings.backup.error": "Ошибка скачивания резервной копии", + "settings.backup.hint": "Скачать всю конфигурацию (устройства, цели, потоки, шаблоны, автоматизации) в виде одного JSON-файла.", + "settings.backup.label": "Резервное копирование", + "settings.backup.success": "Резервная копия скачана", + "settings.brightness": "Яркость:", + "settings.brightness.hint": "Общая яркость для этого устройства (0-100%)", + "settings.button.cancel": "Отмена", + "settings.button.close": "Закрыть", + "settings.button.save": "Сохранить Изменения", + "settings.capture.failed": "Не удалось сохранить настройки захвата", + "settings.capture.saved": "Настройки захвата обновлены", + "settings.capture.title": "Настройки Захвата", + "settings.capture_template": "Шаблон Движка:", + "settings.capture_template.hint": "Движок захвата экрана и конфигурация для этого устройства", + "settings.daylight_timezone.hint": "IANA часовой пояс, в котором каждый дневной цикл «реального времени» читает настенные часы. Пусто (по умолчанию) — использовать системный часовой пояс сервера.", + "settings.daylight_timezone.label": "Часовой пояс дневного цикла", + "settings.daylight_timezone.save_error": "Не удалось сохранить часовой пояс", + "settings.daylight_timezone.saved": "Часовой пояс дневного цикла сохранён", + "settings.display_index": "Дисплей:", + "settings.display_index.hint": "Какой экран захватывать для этого устройства", + "settings.external_url.hint": "Если указан, этот базовый URL используется в URL-ах вебхуков и других пользовательских ссылках вместо автоопределённого локального IP. Пример: https://myserver.example.com:8080", + "settings.external_url.label": "Внешний URL", + "settings.external_url.placeholder": "https://myserver.example.com:8080", + "settings.external_url.save": "Сохранить", + "settings.external_url.save_error": "Не удалось сохранить внешний URL", + "settings.external_url.saved": "Внешний URL сохранён", + "settings.failed": "Не удалось сохранить настройки", + "settings.fps": "Целевой FPS:", + "settings.fps.hint": "Целевая частота кадров (10-90)", + "settings.general.title": "Основные Настройки", + "settings.health_interval": "Интервал Проверки (с):", + "settings.health_interval.hint": "Как часто проверять статус устройства (5-600 секунд)", + "settings.log_level.desc.critical": "Только критические ошибки", + "settings.log_level.desc.debug": "Подробный вывод для разработки", + "settings.log_level.desc.error": "Только ошибки", + "settings.log_level.desc.info": "Обычные сообщения", + "settings.log_level.desc.warning": "Возможные проблемы", + "settings.log_level.hint": "Изменить подробность логов сервера в реальном времени. DEBUG — максимум деталей, CRITICAL — только критические ошибки.", + "settings.log_level.label": "Уровень логирования", + "settings.log_level.save": "Применить", + "settings.log_level.save_error": "Не удалось изменить уровень логирования", + "settings.log_level.saved": "Уровень логирования изменён", + "settings.logs.clear": "Очистить", + "settings.logs.connect": "Подключить", + "settings.logs.disconnect": "Отключить", + "settings.logs.empty.sub": "Подключите WebSocket-поток, чтобы начать трансляцию.", + "settings.logs.empty.title": "Ожидание журналов", + "settings.logs.error": "Ошибка подключения к журналу", + "settings.logs.filter.all": "Все уровни", + "settings.logs.filter.all_desc": "Все сообщения лога", + "settings.logs.filter.error": "Только ошибки", + "settings.logs.filter.error_desc": "Только ошибки", + "settings.logs.filter.info": "Info+", + "settings.logs.filter.info_desc": "Info, предупреждения и ошибки", + "settings.logs.filter.warning": "Warning+", + "settings.logs.filter.warning_desc": "Только предупреждения и ошибки", + "settings.logs.hint": "Просмотр журнала сервера в реальном времени. Используйте фильтр для отображения нужных уровней.", + "settings.logs.label": "Журнал сервера", + "settings.logs.open_viewer": "Открыть логи", + "settings.logs.patch.connecting": "ПОДКЛЮЧЕНИЕ", + "settings.logs.patch.error": "ОФЛАЙН", + "settings.logs.patch.idle": "ОЖИДАНИЕ", + "settings.logs.patch.live": "ПОТОК", + "settings.logs.stat.err": "ОШИБ", + "settings.logs.stat.lines": "СТРОК", + "settings.logs.stat.warn": "ВНИМ", + "settings.logs.sub": "Живой поток лога сервера с фильтром по уровню. Открывается в полноэкранном слое.", + "settings.mqtt.base_topic_label": "Базовый топик", + "settings.mqtt.client_id_label": "Идентификатор клиента", + "settings.mqtt.enabled": "Включить MQTT", + "settings.mqtt.error_host_required": "Требуется указать хост брокера", + "settings.mqtt.hint": "Настройте подключение к MQTT-брокеру для условий и триггеров автоматизации.", + "settings.mqtt.host_label": "Хост брокера", + "settings.mqtt.label": "MQTT", + "settings.mqtt.password_label": "Пароль", + "settings.mqtt.password_set_hint": "Пароль задан — оставьте пустым, чтобы сохранить", + "settings.mqtt.port_label": "Порт", + "settings.mqtt.save": "Сохранить настройки MQTT", + "settings.mqtt.save_error": "Не удалось сохранить настройки MQTT", + "settings.mqtt.saved": "Настройки MQTT сохранены", + "settings.mqtt.username_label": "Имя пользователя", + "settings.notif_matrix.col.event": "Событие", + "settings.notif_matrix.event_count": "4 СОБЫТИЯ", + "settings.notifications.background.hint": "Постоянно сканировать локальную сеть (mDNS) и последовательные порты на новые LED-устройства. Отключите, чтобы убрать события «найдено/потеряно» в источнике. Перезапустите сервер, чтобы применить.", + "settings.notifications.background.label": "Фоновое обнаружение", + "settings.notifications.background.toggle": "Включить фоновое обнаружение", + "settings.notifications.channel.both.desc": "Снэк и системное уведомление", + "settings.notifications.channel.both.label": "Оба", + "settings.notifications.channel.none.desc": "Не уведомлять об этом событии", + "settings.notifications.channel.none.label": "Выкл", + "settings.notifications.channel.os.desc": "Системное уведомление (работает в фоне браузера)", + "settings.notifications.channel.os.label": "ОС", + "settings.notifications.channel.snack.desc": "Всплывающее сообщение внизу страницы", + "settings.notifications.channel.snack.label": "Снэк", + "settings.notifications.intro_hint": "Выберите способ оповещения для каждого события. «Снэк» — всплывающее сообщение внутри приложения, «ОС» — системное уведомление (требуется разрешение в браузере), «Оба» — оба варианта, «Выкл» — событие игнорируется.", + "settings.notifications.intro_label": "События устройств", + "settings.notifications.permission.denied": "Системные уведомления заблокированы — измените в настройках браузера", + "settings.notifications.permission.grant": "Разрешить", + "settings.notifications.permission.granted": "Системные уведомления включены", + "settings.notifications.permission.hint": "Браузер управляет разрешением на системные уведомления для каждого сайта отдельно. После отказа LedGrab уже не может запросить разрешение снова — его нужно сбросить в браузере. Нажмите на значок сайта (замок) в адресной строке → Настройки сайта → Уведомления → Разрешить, затем перезагрузите страницу.", + "settings.notifications.permission.label": "Разрешение на уведомления ОС", + "settings.notifications.permission.pill.denied": "ЗАБЛОКИРОВАНО", + "settings.notifications.permission.pill.granted": "РАЗРЕШЕНО", + "settings.notifications.permission.state.default": "Разрешение ещё не запрошено", + "settings.notifications.permission.state.denied": "Запрещено — измените в настройках браузера", + "settings.notifications.permission.state.granted": "Разрешено — будут показываться уведомления ОС", + "settings.notifications.row.discovered": "Новое устройство найдено", + "settings.notifications.row.lost": "Найденное устройство исчезло", + "settings.notifications.row.offline": "Устройство пропало", + "settings.notifications.row.online": "Устройство вернулось в сеть", + "settings.notifications.save_error": "Не удалось сохранить настройки уведомлений", + "settings.notifications.saved": "Настройки уведомлений сохранены", + "settings.notifications.test_button": "Отправить тестовое уведомление", + "settings.partial.export_button": "Экспорт", + "settings.partial.export_error": "Ошибка экспорта", + "settings.partial.export_success": "Экспорт выполнен", + "settings.partial.hint": "Экспортировать или импортировать один тип объектов. Импорт заменяет или объединяет данные и перезапускает сервер.", + "settings.partial.import_button": "Импорт из файла", + "settings.partial.import_confirm_merge": "Это ОБЪЕДИНИТ данные {store} и перезапустит сервер. Продолжить?", + "settings.partial.import_confirm_replace": "Это ЗАМЕНИТ все данные {store} и перезапустит сервер. Продолжить?", + "settings.partial.import_error": "Ошибка импорта", + "settings.partial.import_success": "Импорт выполнен", + "settings.partial.label": "Частичный экспорт / импорт", + "settings.partial.merge_label": "Объединить (добавить/перезаписать, сохранить существующие)", + "settings.partial.store.audio_sources": "Аудио-источники", + "settings.partial.store.audio_templates": "Аудио-шаблоны", + "settings.partial.store.automations": "Автоматизации", + "settings.partial.store.capture_templates": "Шаблоны захвата", + "settings.partial.store.color_strip_processing_templates": "Шаблоны обработки полос", + "settings.partial.store.color_strip_sources": "Цветные полосы", + "settings.partial.store.devices": "Устройства", + "settings.partial.store.output_targets": "LED-цели", + "settings.partial.store.pattern_templates": "Шаблоны паттернов", + "settings.partial.store.picture_sources": "Источники изображений", + "settings.partial.store.postprocessing_templates": "Шаблоны постобработки", + "settings.partial.store.scene_presets": "Пресеты сцен", + "settings.partial.store.sync_clocks": "Синхронные часы", + "settings.partial.store.value_sources": "Источники значений", + "settings.rail.group.system": "Система", + "settings.rail.group.workspace": "Рабочая зона", + "settings.restart.button": "Перезапустить", + "settings.restart.sub": "Перезапустить процесс LedGrab. Захват и подключенные устройства приостановятся примерно на 3 секунды.", + "settings.restart_confirm": "Перезапустить сервер? Активные цели будут остановлены.", + "settings.restart_server": "Перезапустить сервер", + "settings.restarting": "Перезапуск сервера...", + "settings.restore.button": "Восстановить", + "settings.restore.confirm": "Это заменит ВСЮ конфигурацию и перезапустит сервер. Вы уверены?", + "settings.restore.error": "Ошибка восстановления", + "settings.restore.hint": "Загрузите ранее сохранённый файл резервной копии для замены всей конфигурации. Сервер перезапустится автоматически.", + "settings.restore.label": "Восстановление конфигурации", + "settings.restore.restart_timeout": "Сервер не отвечает. Обновите страницу вручную.", + "settings.restore.restarting": "Сервер перезапускается...", + "settings.restore.success": "Конфигурация восстановлена", + "settings.save_bar.revert": "Отменить", + "settings.save_bar.save": "Сохранить", + "settings.save_bar.unsaved": "Несохранённые изменения в поле", + "settings.saved": "Настройки успешно сохранены", + "settings.saved_backups.delete": "Удалить", + "settings.saved_backups.delete_confirm": "Удалить эту резервную копию?", + "settings.saved_backups.delete_error": "Не удалось удалить копию", + "settings.saved_backups.download": "Скачать", + "settings.saved_backups.empty": "Нет сохранённых копий", + "settings.saved_backups.hint": "Файлы авто-бэкапа на сервере. Скачайте для локального хранения или удалите для освобождения места.", + "settings.saved_backups.label": "Сохранённые копии", + "settings.saved_backups.restore": "Восстановить", + "settings.saved_backups.type.auto": "авто", + "settings.saved_backups.type.manual": "ручной", + "settings.section.adapter": "Адаптер", + "settings.section.api_keys": "Идентификация и API", + "settings.section.auth": "Авторизация", + "settings.section.behavior": "Поведение", + "settings.section.broker": "Брокер", + "settings.section.configure": "Настройка", + "settings.section.connection": "Подключение", + "settings.section.controls": "Управление", + "settings.section.destructive": "ОПАСНО", + "settings.section.diagnostics": "Диагностика", + "settings.section.engine": "Движок", + "settings.section.file": "Файл", + "settings.section.filtering": "Фильтрация", + "settings.section.filters": "Фильтры", + "settings.section.gradient": "Градиент", + "settings.section.hardware": "Оборудование", + "settings.section.history": "История", + "settings.section.identity": "Идентификация", + "settings.section.layout": "Раскладка", + "settings.section.lifecycle": "Жизненный цикл", + "settings.section.line_properties": "Параметры линии", + "settings.section.loopback": "Локальный доступ", + "settings.section.manual": "Вручную", + "settings.section.mappings": "Сопоставления", + "settings.section.notes": "Заметки", + "settings.section.notif_channels": "Каналы", + "settings.section.notif_discovery": "Обнаружение", + "settings.section.notif_permission": "Разрешение ОС", + "settings.section.offsets": "Смещения", + "settings.section.output": "Вывод", + "settings.section.preview": "Превью", + "settings.section.protocol": "Протокол", + "settings.section.provider": "Провайдер", + "settings.section.refresh": "Обновление", + "settings.section.restart": "Перезапуск", + "settings.section.routing": "Маршрутизация", + "settings.section.server": "Сервер", + "settings.section.source": "Источник", + "settings.section.strip": "Лента", + "settings.section.test": "Тест", + "settings.section.test_setup": "Параметры теста", + "settings.section.timing": "Тайминг", + "settings.section.type": "Тип", + "settings.shutdown_action.hint": "Что происходит с LED-целями при остановке сервера. «Остановить цели» — обычная последовательность остановки, устройства с авто-восстановлением восстановят прежнее состояние. «Ничего» — оставить свет таким, каким он был на последнем кадре.", + "settings.shutdown_action.label": "Действие при выключении", + "settings.shutdown_action.opt.nothing": "Ничего", + "settings.shutdown_action.opt.nothing_desc": "Оставить свет на последнем кадре", + "settings.shutdown_action.opt.stop": "Остановить цели", + "settings.shutdown_action.opt.stop_desc": "Обычная остановка (учитывается авто-восстановление устройств)", + "settings.shutdown_action.save_error": "Не удалось сохранить действие при выключении", + "settings.shutdown_action.saved": "Действие при выключении сохранено", + "settings.tab.about": "О программе", + "settings.tab.activity_log": "Журнал активности", + "settings.tab.appearance": "Оформление", + "settings.tab.backup": "Бэкап", + "settings.tab.general": "Основные", + "settings.tab.mqtt": "MQTT", + "settings.tab.notifications": "Уведомления", + "settings.tab.updates": "Обновления", + "settings.title": "Настройки", + "settings.url.hint": "IP адрес или имя хоста устройства", + "setup.copied": "Скопировано в буфер обмена", + "setup.copy": "Скопировать фрагмент", + "setup.description": "На этом сервере LedGrab не настроены API-ключи, поэтому доступ с других устройств в сети отключён из соображений безопасности. Чтобы разрешить доступ по локальной сети, настройте ключ на машине, где работает сервер.", + "setup.hint_openssl": "Сгенерировать надёжный ключ: Linux/macOS — \u003ccode\u003eopenssl rand -hex 32\u003c/code\u003e; Windows PowerShell — \u003ccode\u003e[guid]::NewGuid().ToString(\u0027N\u0027) + [guid]::NewGuid().ToString(\u0027N\u0027)\u003c/code\u003e.", + "setup.retry": "Я настроил ключ — проверить снова", + "setup.step1_label": "1. На машине с сервером откройте \u003ccode\u003econfig/default_config.yaml\u003c/code\u003e:", + "setup.step2_label": "2. Перезапустите сервер, затем перезагрузите эту страницу и войдите с этим ключом.", + "setup.step3_label": "Либо откройте LedGrab прямо на машине с сервером (через loopback), ключ не нужен:", + "setup.still_required": "Сервер всё ещё сообщает об отсутствии API-ключей. Проверьте, что файл сохранён и сервер перезапущен.", + "setup.title": "Требуется настройка сервера", + "sidebar.fps": "FPS", + "sidebar.load": "Нагр.", + "sidebar.workspaces": "Разделы", + "stream.error.clone_capture_failed": "Не удалось клонировать шаблон захвата", + "stream.error.clone_picture_failed": "Не удалось клонировать источник изображения", + "stream.error.clone_pp_failed": "Не удалось клонировать шаблон постобработки", + "streams.add": "Добавить Источник", + "streams.add.processed": "Добавить Обработанный", + "streams.add.raw": "Добавить Захват Экрана", + "streams.add.static_image": "Добавить статическое изображение (источник)", + "streams.add.video": "Добавить видеоисточник", + "streams.capture_template": "Шаблон Движка:", + "streams.capture_template.hint": "Шаблон движка, определяющий способ захвата экрана", + "streams.created": "Источник успешно создан", + "streams.delete.confirm": "Вы уверены, что хотите удалить этот источник?", + "streams.deleted": "Источник успешно удалён", + "streams.description": "Источники определяют конвейер захвата. Сырой источник захватывает экран с помощью шаблона захвата. Обработанный источник применяет постобработку к другому источнику. Назначайте источники устройствам.", + "streams.description_label": "Описание (необязательно):", + "streams.description_placeholder": "Опишите этот источник...", + "streams.display": "Дисплей:", + "streams.display.hint": "Какой экран захватывать", + "streams.edit": "Редактировать Источник", + "streams.edit.processed": "Редактировать Обработанный Источник", + "streams.edit.raw": "Редактировать Захват Экрана", + "streams.edit.static_image": "Редактировать статическое изображение (источник)", + "streams.edit.video": "Редактировать видеоисточник", + "streams.error.delete": "Не удалось удалить источник", + "streams.error.load": "Не удалось загрузить источники", + "streams.error.required": "Пожалуйста, заполните все обязательные поля", + "streams.group.assets": "Ресурсы", + "streams.group.audio": "Аудио", + "streams.group.audio_processing": "Обработка звука", + "streams.group.audio_templates": "Аудио шаблоны", + "streams.group.color_strip": "Цветовые Полосы", + "streams.group.css_processing": "Шаблоны Обработки", + "streams.group.game": "Игровая интеграция", + "streams.group.http": "HTTP", + "streams.group.proc_templates": "Шаблоны фильтров", + "streams.group.processed": "Источники", + "streams.group.raw": "Источники", + "streams.group.raw_templates": "Шаблоны движка", + "streams.group.static_image": "Статические", + "streams.group.sync": "Часы синхронизации", + "streams.group.value": "Источники значений", + "streams.group.video": "Видео", + "streams.group.weather": "Погода", + "streams.image_asset": "Изображение:", + "streams.image_asset.search": "Поиск изображений…", + "streams.image_asset.select": "Выберите изображение…", + "streams.modal.loading": "Загрузка...", + "streams.name": "Имя Источника:", + "streams.name.placeholder": "Мой Источник", + "streams.pp_template": "Шаблон Фильтра:", + "streams.pp_template.hint": "Шаблон фильтра для применения к источнику", + "streams.section.streams": "Источники", + "streams.source": "Источник:", + "streams.source.hint": "Источник, к которому применяются фильтры обработки", + "streams.target_fps": "Целевой FPS:", + "streams.target_fps.hint": "Целевое количество кадров в секунду (1-90)", + "streams.test.duration": "Длительность Захвата (с):", + "streams.test.error.failed": "Тест источника не удался", + "streams.test.run": "Запустить", + "streams.test.running": "Тестирование источника...", + "streams.test.title": "Тест Источника", + "streams.title": "Входы", + "streams.type": "Тип:", + "streams.type.processed": "Обработанный", + "streams.type.raw": "Захват экрана", + "streams.type.static_image": "Статическое изображение", + "streams.updated": "Источник успешно обновлён", + "streams.video_asset": "Видео:", + "streams.video_asset.search": "Поиск видео…", + "streams.video_asset.select": "Выберите видео…", + "sync_clock.action.pause": "Приостановить", + "sync_clock.action.reset": "Сбросить", + "sync_clock.action.resume": "Возобновить", + "sync_clock.add": "Добавить часы", + "sync_clock.created": "Часы синхронизации созданы", + "sync_clock.delete.confirm": "Удалить эти часы синхронизации? Привязанные источники потеряют синхронизацию и будут работать на скорости по умолчанию.", + "sync_clock.deleted": "Часы синхронизации удалены", + "sync_clock.description": "Описание (необязательно):", + "sync_clock.description.hint": "Необязательные заметки о назначении этих часов", + "sync_clock.description.placeholder": "Необязательное описание", + "sync_clock.edit": "Редактировать часы", + "sync_clock.elapsed": "Прошло времени", + "sync_clock.error.load": "Не удалось загрузить часы синхронизации", + "sync_clock.error.name_required": "Название часов обязательно", + "sync_clock.group.title": "Часы синхронизации", + "sync_clock.name": "Название:", + "sync_clock.name.hint": "Описательное название для этих часов синхронизации", + "sync_clock.name.placeholder": "Основные часы анимации", + "sync_clock.paused": "Часы приостановлены", + "sync_clock.reset_done": "Часы сброшены на ноль", + "sync_clock.resumed": "Часы возобновлены", + "sync_clock.speed": "Скорость:", + "sync_clock.speed.hint": "Множитель скорости анимации для всех привязанных источников. 1.0 = обычная, 2.0 = двойная, 0.5 = половинная.", + "sync_clock.status.paused": "Приостановлено", + "sync_clock.status.running": "Работает", + "sync_clock.updated": "Часы синхронизации обновлены", + "tags.hint": "Назначьте теги для группировки и фильтрации карточек", + "tags.label": "Теги", + "tags.placeholder": "Добавить тег...", + "target.error.clone_failed": "Не удалось клонировать цель", + "target.error.delete_failed": "Не удалось удалить цель", + "target.error.editor_open_failed": "Не удалось открыть редактор цели", + "target.error.load_failed": "Не удалось загрузить цель", + "target.error.start_failed": "Не удалось запустить цель", + "target.error.stop_failed": "Не удалось остановить цель", + "targets.adaptive_fps": "Адаптивный FPS:", + "targets.adaptive_fps.hint": "Автоматически снижает частоту отправки, когда устройство перестаёт отвечать, и постепенно восстанавливает её при стабилизации. Рекомендуется для WiFi-устройств со слабым сигналом.", + "targets.add": "Добавить Цель", + "targets.border_width": "Ширина границы (px):", + "targets.border_width.hint": "Сколько пикселей от края экрана выбирать для цвета LED (1-100)", + "targets.brightness": "Яркость:", + "targets.brightness.hint": "Множитель яркости (0–1). Можно привязать к источнику значений для динамического управления.", + "targets.brightness_vs": "Источник яркости:", + "targets.brightness_vs.hint": "Необязательный источник значений для динамического управления яркостью каждый кадр (переопределяет яркость устройства)", + "targets.brightness_vs.none": "Нет (яркость устройства)", + "targets.button.start": "Запустить", + "targets.button.stop": "Остановить", + "targets.color_strip_source": "Источник цветовой полосы:", + "targets.color_strip_source.hint": "Выберите источник цветовой полосы, который предоставляет цвета LED для этой цели", + "targets.created": "Цель успешно создана", + "targets.delete.confirm": "Вы уверены, что хотите удалить эту цель?", + "targets.deleted": "Цель успешно удалена", + "targets.description": "Цели связывают источники цветовых полос с устройствами вывода. Каждая цель ссылается на устройство и источник цветовой полосы.", + "targets.device": "Устройство:", + "targets.device.hint": "Выберите LED устройство для передачи данных", + "targets.device.none": "-- Выберите устройство --", + "targets.edit": "Редактировать Цель", + "targets.error.delete": "Не удалось удалить цель", + "targets.error.load": "Не удалось загрузить цели", + "targets.error.name_required": "Введите название цели", + "targets.error.required": "Пожалуйста, заполните все обязательные поля", + "targets.failed": "Не удалось загрузить цели", + "targets.fps": "Целевой FPS:", + "targets.fps.hint": "Целевая частота кадров для захвата и обновления LED (1-90)", + "targets.fps.rec": "Макс. аппаратный ≈ {fps} fps ({leds} LED)", + "targets.interpolation": "Режим интерполяции:", + "targets.interpolation.average": "Среднее", + "targets.interpolation.dominant": "Доминантный", + "targets.interpolation.hint": "Как вычислять цвет LED из выбранных пикселей", + "targets.interpolation.median": "Медиана", + "targets.keepalive_interval": "Интервал поддержания связи:", + "targets.keepalive_interval.hint": "Как часто повторно отправлять последний кадр при статичном источнике для удержания устройства в режиме live (0.5-5.0с)", + "targets.loading": "Загрузка целей...", + "targets.metrics.actual_fps": "Факт. FPS", + "targets.metrics.errors": "Ошибки", + "targets.metrics.frames": "Кадры", + "targets.metrics.target_fps": "Целев. FPS", + "targets.min_brightness_threshold": "Мин. порог яркости:", + "targets.min_brightness_threshold.hint": "Если итоговая яркость (яркость пикселей × яркость устройства/источника) ниже этого значения, светодиоды полностью выключаются (0 = отключено)", + "targets.name": "Имя Цели:", + "targets.name.placeholder": "Моя Цель", + "targets.no_css": "Нет источника", + "targets.none": "Цели не настроены", + "targets.power_limit": "Макс. ток (ABL):", + "targets.power_limit.hint": "Ограничивает расчётный ток ленты бюджетом блока питания, чтобы избежать просадок напряжения (сдвиг цвета, мерцание, перезагрузки) на ярких/белых сценах. Укажите номинальный ток вашего БП с запасом. 0 = без ограничения.", + "targets.power_limit.ma_suffix": "мА (0 = без ограничения)", + "targets.power_limit.per_led": "мА на светодиод (полный белый):", + "targets.protocol": "Протокол:", + "targets.protocol.ddp": "DDP (UDP)", + "targets.protocol.ddp.desc": "Быстрые UDP-пакеты — рекомендуется", + "targets.protocol.hint": "DDP отправляет пиксели по быстрому UDP (рекомендуется). HTTP использует JSON API — медленнее, но надёжнее, ограничение ~500 LED.", + "targets.protocol.http": "HTTP", + "targets.protocol.http.desc": "JSON API — медленнее, ≤500 LED", + "targets.protocol.serial": "Serial", + "targets.protocol.udp": "WLED UDP (realtime)", + "targets.protocol.udp.desc": "Нативный realtime WLED — корректный RGBW и авто-возврат при обрыве потока", + "targets.section.advanced": "Расширенные", + "targets.section.color_strips": "Источники цветовых полос", + "targets.section.devices": "Устройства", + "targets.section.pattern_templates": "Шаблоны Паттернов", + "targets.section.specific_settings": "Специальные настройки", + "targets.section.targets": "Цели", + "targets.smoothing": "Сглаживание:", + "targets.smoothing.hint": "Временное смешивание между кадрами (0=нет, 1=полное). Уменьшает мерцание.", + "targets.source": "Источник:", + "targets.source.hint": "Какой источник изображения захватывать и обрабатывать", + "targets.source.none": "-- Источник не назначен --", + "targets.status.error": "Ошибка", + "targets.status.idle": "Ожидание", + "targets.status.processing": "Обработка", + "targets.stop_all.button": "Остановить все", + "targets.stop_all.error": "Не удалось остановить цели", + "targets.stop_all.none_running": "Нет запущенных целей", + "targets.stop_all.stopped": "Остановлено целей: {count}", + "targets.subtab.led": "LED", + "targets.subtab.wled": "LED", + "targets.title": "Каналы", + "targets.updated": "Цель успешно обновлена", + "templates.add": "Добавить Шаблон Движка", + "templates.config": "Конфигурация", + "templates.config.camera_backend.auto": "Автовыбор лучшего бэкенда", + "templates.config.camera_backend.dshow": "Windows DirectShow", + "templates.config.camera_backend.msmf": "Windows Media Foundation", + "templates.config.camera_backend.v4l2": "Linux Video4Linux2", + "templates.config.default": "По умолчанию", + "templates.config.none": "Нет дополнительных настроек", + "templates.config.resolution.1080p": "Full HD", + "templates.config.resolution.1440p": "QHD / 2K", + "templates.config.resolution.2160p": "4K UHD — максимум CPU и трафика", + "templates.config.resolution.480p": "VGA — минимум CPU и трафика", + "templates.config.resolution.720p": "HD", + "templates.config.resolution.auto": "Открыть в максимальном поддерживаемом разрешении камеры", + "templates.config.show": "Показать конфигурацию", + "templates.created": "Шаблон успешно создан", + "templates.delete.confirm": "Вы уверены, что хотите удалить этот шаблон?", + "templates.deleted": "Шаблон успешно удалён", + "templates.description": "Шаблоны захвата определяют, как захватывается экран. Каждый шаблон использует определённый движок захвата (MSS, DXcam, WGC) с настраиваемыми параметрами. Назначайте шаблоны устройствам для оптимальной производительности.", + "templates.description.label": "Описание (необязательно):", + "templates.description.placeholder": "Опишите этот шаблон...", + "templates.edit": "Редактировать Шаблон Движка", + "templates.empty": "Шаблоны захвата не настроены", + "templates.engine": "Движок Захвата:", + "templates.engine.android_camera.desc": "Захват камеры устройства (Camera2)", + "templates.engine.bettercam.desc": "DirectX, высокая производительность", + "templates.engine.camera.desc": "Захват USB/IP камеры", + "templates.engine.demo.desc": "Тестовый анимированный шаблон (демо)", + "templates.engine.dxcam.desc": "DirectX, низкая задержка", + "templates.engine.hint": "Выберите технологию захвата экрана", + "templates.engine.mediaprojection.desc": "Нативный захват экрана Android", + "templates.engine.mss.desc": "Кроссплатформенный, чистый Python", + "templates.engine.scrcpy.desc": "Зеркалирование экрана Android (adb screencap)", + "templates.engine.scrcpy_client.desc": "Android H.264 поток, высокая частота", + "templates.engine.select": "Выберите движок...", + "templates.engine.unavailable": "Недоступен", + "templates.engine.unavailable.hint": "Этот движок недоступен в вашей системе", + "templates.engine.wgc.desc": "Windows Graphics Capture", + "templates.error.delete": "Не удалось удалить шаблон", + "templates.error.engines": "Не удалось загрузить движки", + "templates.error.load": "Не удалось загрузить шаблоны", + "templates.error.load_failed": "Не удалось загрузить шаблон", + "templates.error.required": "Пожалуйста, заполните все обязательные поля", + "templates.error.save_failed": "Не удалось сохранить шаблон", + "templates.loading": "Загрузка шаблонов...", + "templates.name": "Имя Шаблона:", + "templates.name.placeholder": "Мой Пользовательский Шаблон", + "templates.test.border_width": "Ширина Границы (px):", + "templates.test.description": "Протестируйте этот шаблон перед сохранением, чтобы увидеть предпросмотр захвата и метрики производительности.", + "templates.test.device": "Устройство:", + "templates.test.device.select": "Выберите устройство...", + "templates.test.display": "Дисплей:", + "templates.test.display.select": "Выберите дисплей...", + "templates.test.duration": "Длительность Захвата (с):", + "templates.test.error.failed": "Тест не удался", + "templates.test.error.no_device": "Пожалуйста, выберите устройство", + "templates.test.error.no_display": "Пожалуйста, выберите дисплей", + "templates.test.error.no_engine": "Пожалуйста, выберите движок захвата", + "templates.test.results.actual_fps": "Факт. FPS", + "templates.test.results.avg_capture_time": "Средн. Захват", + "templates.test.results.borders": "Извлечение Границ", + "templates.test.results.bottom": "Снизу", + "templates.test.results.capture_time": "Захват", + "templates.test.results.duration": "Длительность", + "templates.test.results.extraction_time": "Извлечение", + "templates.test.results.frame_count": "Кадры", + "templates.test.results.left": "Слева", + "templates.test.results.max_fps": "Макс. FPS", + "templates.test.results.performance": "Производительность", + "templates.test.results.preview": "Полный Предпросмотр Захвата", + "templates.test.results.resolution": "Разрешение:", + "templates.test.results.right": "Справа", + "templates.test.results.top": "Сверху", + "templates.test.results.total_time": "Всего", + "templates.test.run": "Запустить", + "templates.test.running": "Выполняется тест...", + "templates.test.title": "Тест Захвата", + "templates.title": "Шаблоны Движков", + "templates.updated": "Шаблон успешно обновлён", + "test.avg_capture": "Сред", + "test.fps": "Кадр/с", + "test.frames": "Кадры", + "theme.switched.dark": "Переключено на тёмную тему", + "theme.switched.light": "Переключено на светлую тему", + "theme.switched.system": "Переключено на системную тему", + "theme.toggle": "Переключить тему", + "time.days_ago": "{n} дн. назад", + "time.hours_ago": "{n} ч. назад", + "time.just_now": "только что", + "time.minutes_ago": "{n} мин. назад", + "time.seconds_ago": "{n} сек. назад", + "time.today": "Сегодня", + "time.yesterday": "Вчера", + "tour.accent": "Цвет акцента — настройте цвет интерфейса по своему вкусу.", + "tour.activity_log": "Вкладка «Активность» — постоянный журнал всего, что делает LedGrab: изменения сущностей, авторизация, подключения устройств и системные действия. Используйте фильтры, поиск и экспорт в CSV или JSON.", + "tour.api": "API Документация — интерактивная документация REST API на базе Swagger.", + "tour.auto.add": "Нажмите + для создания новой автоматизации с условиями и сценой для активации.", + "tour.auto.card": "Каждая карточка показывает статус автоматизации, условия и кнопки управления.", + "tour.auto.list": "Автоматизации — автоматизируйте активацию сцен по времени, звуку или значениям.", + "tour.auto.scenes_add": "Нажмите + для захвата текущего состояния системы как нового пресета сцены.", + "tour.auto.scenes_card": "Каждая карточка сцены показывает количество целей/устройств. Нажмите для редактирования, перезахвата или активации.", + "tour.auto.scenes_list": "Сцены — сохранённые состояния системы, которые автоматизации могут активировать или вы можете применить вручную.", + "tour.automations": "Автоматизации — автоматизируйте переключение сцен по расписанию, звуку или значениям.", + "tour.dash.automations": "Автоматизации — статус активных автоматизаций и быстрое включение/выключение.", + "tour.dash.customize_btn": "Кнопка «Настроить» — открывает боковую панель, где можно изменить порядок секций, скрыть их или подстроить раскладку дашборда.", + "tour.dash.customize_global": "Глобальные настройки — ширина контента, уровень анимаций, режим производительности (system/app/both) и окно выборки графиков.", + "tour.dash.customize_panel": "Настройка дашборда — центр управления раскладкой. Изменения применяются мгновенно; закрытие — Esc или кнопка ×.", + "tour.dash.customize_perf_cells": "Ячейки производительности — выбирайте, какие метрики отображать в секции Performance, настраивайте ширину, окно выборки и шкалу Y.", + "tour.dash.customize_presets": "Пресеты — применяйте готовые раскладки (компактная, фокус, полная) одним кликом. Выбор пресета сбрасывает ручные настройки.", + "tour.dash.customize_sections": "Секции — перетаскивайте для смены порядка; «глаз» скрывает; «шеврон» делает секцию свёрнутой по умолчанию. Также для каждой секции можно выбрать плотность.", + "tour.dash.integrations": "Интеграции — статус подключения для Weather, Home Assistant и MQTT.", + "tour.dash.perf": "Производительность — графики FPS в реальном времени, метрики задержки и интервал опроса.", + "tour.dash.running": "Запущенные цели — метрики стриминга и быстрая остановка.", + "tour.dash.scenes": "Пресеты сцен — сохранённые состояния системы, применяются одним кликом.", + "tour.dash.stopped": "Остановленные цели — готовы к запуску одним нажатием.", + "tour.dash.sync_clocks": "Синхро-часы — общие таймеры для синхронизации анимаций между источниками.", + "tour.dash.targets": "Каналы — все ваши цели в одном месте, разделённые на запущенные и остановленные.", + "tour.dashboard": "Дашборд — обзор запущенных целей, автоматизаций и состояния устройств.", + "tour.graph": "Граф — визуальный обзор всех сущностей и их связей. Перетаскивайте порты для соединения, правый клик по связям для отключения.", + "tour.int.game": "Игры — реакция на игровые события (Minecraft, Hammer of God и др.) с активацией сцен и цветовых эффектов на основе состояния игры.", + "tour.int.home_assistant": "Home Assistant — подключение к экземпляру HA. Лампы, датчики и сущности становятся источниками значений и целями вывода.", + "tour.int.mqtt": "MQTT — подписывайтесь на топики брокера для передачи данных внешних датчиков, автоматизаций или удалённых триггеров в LedGrab.", + "tour.int.nav": "Используйте этот выпадающий список для переключения между типами интеграций: погода, Home Assistant, MQTT и игровые интеграции.", + "tour.int.weather": "Погода — получайте данные о погоде (температура, условия, прогноз) в виде источников значений для эффектов от окружающей среды.", + "tour.integrations": "Интеграции — подключайте внешние сервисы: погоду, Home Assistant, MQTT и игровые интеграции.", + "tour.language": "Язык — выберите предпочитаемый язык интерфейса.", + "tour.restart": "Запустить тур заново", + "tour.search": "Поиск — быстрый поиск и переход к любому объекту по Ctrl+K.", + "tour.settings": "Настройки — резервное копирование и восстановление конфигурации.", + "tour.sources": "Источники — управление шаблонами захвата, источниками изображений, звука и цветовых полос.", + "tour.src.audio": "Аудио — захват микрофона или системного звука и обработка через шаблоны для реактивных эффектов.", + "tour.src.color_strip": "Цветовые полосы — определяют, как области экрана сопоставляются с LED-сегментами.", + "tour.src.nav": "Используйте этот выпадающий список для переключения между типами источников — изображения, цветовые полосы, аудио, значения, синхронизация, ассеты.", + "tour.src.processed": "Обработка — применяйте эффекты: размытие, яркость, цветокоррекция.", + "tour.src.raw": "Raw — источники захвата экрана с ваших дисплеев.", + "tour.src.static": "Статичные изображения — тестируйте настройку с файлами изображений.", + "tour.src.sync": "Синхро-часы — общие таймеры для синхронизации анимаций между несколькими источниками.", + "tour.src.templates": "Шаблоны захвата — переиспользуемые конфигурации (разрешение, FPS, обрезка).", + "tour.src.value": "Источники значений — динамические числа или цвета, управляемые временем суток, аудио, системными метриками, сущностями Home Assistant, градиентами или расписаниями. Используются для анимации эффектов, модуляции цветовых полос и триггеров автоматизаций.", + "tour.targets": "Цели — добавляйте WLED-устройства, настраивайте LED-цели с захватом и калибровкой.", + "tour.tgt.css": "Цветовые полосы — определите, как области экрана соответствуют сегментам LED.", + "tour.tgt.devices": "Устройства — ваши LED-контроллеры, найденные в сети.", + "tour.tgt.ha_light_targets": "Цели HA-ламп — выберите лампу Home Assistant и источник цвета/значения для управления.", + "tour.tgt.nav": "Используйте этот выпадающий список для переключения между типами целей: LED-устройства, LED-цели и лампы Home Assistant.", + "tour.tgt.targets": "LED-цели — объедините устройство, цветовую полосу и источник захвата для стриминга.", + "tour.theme": "Тема — переключение между тёмной и светлой темой.", + "tour.welcome": "Добро пожаловать в LED Grab! Этот краткий тур познакомит вас с интерфейсом. Используйте стрелки или кнопки для навигации.", + "transport.meta.cpu": "CPU", + "transport.meta.mem": "Память", + "transport.meta.poll": "Опрос", + "transport.meta.poll_hint": "Интервал опроса (клик: 1с → 2с → 5с → 10с)", + "transport.meta.uptime": "Время", + "transport.status.armed": "Активно · {n}", + "transport.status.ready": "Готов", + "tree.group.audio": "Аудио", + "tree.group.audio_capture": "Захват", + "tree.group.audio_processed": "Обработка", + "tree.group.capture": "Захват экрана", + "tree.group.game": "Игры", + "tree.group.picture": "Источники изображений", + "tree.group.processing": "Обработанные", + "tree.group.static": "Статичные", + "tree.group.strip": "Цветовые полосы", + "tree.group.utility": "Утилиты", + "tree.leaf.engine_templates": "Шаблоны движка", + "tree.leaf.filter_templates": "Шаблоны фильтров", + "tree.leaf.images": "Изображения", + "tree.leaf.processing_templates": "Шаблоны обработки", + "tree.leaf.sources": "Источники", + "tree.leaf.templates": "Шаблоны", + "tree.leaf.video": "Видео", + "update.apply_confirm": "Скачать и установить версию {version}? Сервер будет перезапущен автоматически.", + "update.apply_error": "Ошибка обновления", + "update.apply_now": "Обновить сейчас", + "update.applying": "Применяется обновление…", + "update.assets.desc.android": "Android — sideload на Android 7.0+", + "update.assets.desc.android_bundle": "Android App Bundle (Play Store)", + "update.assets.desc.archive": "Сжатый архив", + "update.assets.desc.ios": "Приложение iOS", + "update.assets.desc.linux_appimage": "Linux portable — один исполняемый файл", + "update.assets.desc.linux_deb": "Пакет Debian / Ubuntu", + "update.assets.desc.linux_rpm": "Пакет Fedora / RHEL", + "update.assets.desc.linux_sandbox": "Sandbox-пакет Linux", + "update.assets.desc.linux_tarball": "Архив Linux — распакуйте и запустите ./run.sh", + "update.assets.desc.macos_dmg": "Образ диска macOS — перетащите в «Программы»", + "update.assets.desc.macos_installer": "Установщик macOS", + "update.assets.desc.tarball": "Tar-архив", + "update.assets.desc.windows_exe": "Исполняемый файл Windows", + "update.assets.desc.windows_installer": "Установщик Windows — ярлык в меню «Пуск», опциональный автозапуск, деинсталлятор", + "update.assets.desc.windows_msi": "MSI-установщик Windows", + "update.assets.desc.windows_portable": "Windows portable — распакуйте в любую папку и запустите LedGrab.bat", + "update.assets.desc.zip_archive": "Архив ZIP", + "update.assets.title": "Загрузки", + "update.auto_check_hint": "Периодически проверять наличие новых версий в фоновом режиме.", + "update.auto_check_label": "Автоматическая проверка", + "update.available": "Доступна версия {version}", + "update.badge_tooltip": "Доступна новая версия — нажмите для подробностей", + "update.channel.prerelease": "Пре-релиз", + "update.channel.prerelease_desc": "Включая альфа, бета и RC сборки", + "update.channel.stable": "Стабильный", + "update.channel.stable_desc": "Только стабильные релизы", + "update.channel_label": "Канал", + "update.check_error": "Ошибка проверки обновлений", + "update.check_now": "Проверить обновления", + "update.current_version": "Текущая версия:", + "update.dismiss": "Скрыть", + "update.downloading": "Загрузка…", + "update.enable": "Включить автопроверку", + "update.install_type.dev": "Разработка", + "update.install_type.docker": "Docker", + "update.install_type.installer": "Установщик Windows", + "update.install_type.portable": "Портативная", + "update.install_type_label": "Тип установки:", + "update.interval_label": "Интервал проверки", + "update.last_check": "Последняя проверка", + "update.never": "никогда", + "update.pill.available": "ДОСТУПНО ОБНОВЛЕНИЕ", + "update.pill.error": "ОШИБКА", + "update.pill.updated": "АКТУАЛЬНО", + "update.prerelease": "пре-релиз", + "update.release_notes": "Примечания к релизу", + "update.release_notes_hint": "Что нового в доступной версии — прочитайте список изменений перед применением.", + "update.release_notes_open": "Открыть", + "update.save_settings": "Сохранить настройки", + "update.settings_save_error": "Не удалось сохранить настройки обновлений", + "update.settings_saved": "Настройки обновлений сохранены", + "update.status_label": "Статус обновления", + "update.up_to_date": "Установлена последняя версия", + "update.view_release": "Подробнее", + "update.view_release_notes": "Открыть примечания к релизу", + "validation.required": "Обязательное поле", + "value_source.adaptive_max_value": "Макс. значение:", + "value_source.adaptive_max_value.hint": "Максимальная выходная яркость", + "value_source.adaptive_min_value": "Мин. значение:", + "value_source.adaptive_min_value.hint": "Минимальная выходная яркость", + "value_source.add": "Добавить источник значений", + "value_source.audio_max_value": "Макс. значение:", + "value_source.audio_max_value.hint": "Выход при максимальном уровне звука", + "value_source.audio_min_value": "Мин. значение:", + "value_source.audio_min_value.hint": "Выход при тишине (напр. 0.3 = минимум 30% яркости)", + "value_source.audio_source": "Аудиоисточник:", + "value_source.audio_source.hint": "Аудиоисточник для считывания уровня звука", + "value_source.auto_gain": "Авто-усиление:", + "value_source.auto_gain.enable": "Включить авто-усиление", + "value_source.auto_gain.hint": "Автоматически нормализует уровни звука, чтобы выходное значение использовало полный диапазон независимо от громкости входного сигнала", + "value_source.created": "Источник значений создан", + "value_source.daylight.enable_real_time": "Следовать за часами", + "value_source.daylight.latitude": "Широта:", + "value_source.daylight.latitude.hint": "Географическая широта (-90 до 90). Делает переходы рассвета и заката круче или плавнее.", + "value_source.daylight.longitude": "Долгота:", + "value_source.daylight.longitude.hint": "Географическая долгота (-180 до 180). Сдвигает солнечный полдень для точного совпадения восхода и заката с настенными часами.", + "value_source.daylight.real_time": "Реальное время", + "value_source.daylight.speed": "Скорость:", + "value_source.daylight.speed.hint": "Множитель скорости цикла. 1.0 = полный цикл день/ночь за ~4 минуты.", + "value_source.daylight.speed_label": "Скорость", + "value_source.daylight.use_real_time": "Реальное время:", + "value_source.daylight.use_real_time.hint": "Значение следует за реальным временем суток. Скорость игнорируется.", + "value_source.delete.confirm": "Удалить этот источник значений?", + "value_source.deleted": "Источник значений удалён", + "value_source.description": "Описание (необязательно):", + "value_source.description.hint": "Необязательные заметки об этом источнике значений", + "value_source.description.placeholder": "Опишите этот источник значений...", + "value_source.edit": "Редактировать источник значений", + "value_source.error.load": "Не удалось загрузить источник значений", + "value_source.error.name_required": "Введите название", + "value_source.error.schedule_min": "Расписание требует минимум 2 временные точки", + "value_source.game_event.default_value": "Значение по умолчанию:", + "value_source.game_event.default_value.hint": "Выходное значение, когда события не поступают в пределах таймаута.", + "value_source.game_event.event_type": "Тип события:", + "value_source.game_event.event_type.hint": "Непрерывное игровое событие (здоровье, мана, патроны и т.д.).", + "value_source.game_event.integration": "Игровая интеграция:", + "value_source.game_event.integration.hint": "Выберите игровую интеграцию для этого источника значений.", + "value_source.game_event.max_game_value": "Макс. игровое значение:", + "value_source.game_event.max_game_value.hint": "Исходное игровое значение, соответствующее 1.0.", + "value_source.game_event.min_game_value": "Мин. игровое значение:", + "value_source.game_event.min_game_value.hint": "Исходное игровое значение, соответствующее 0.0.", + "value_source.game_event.smoothing": "Сглаживание:", + "value_source.game_event.smoothing.hint": "Коэффициент EMA-сглаживания. 0 = мгновенно, выше = плавнее.", + "value_source.game_event.timeout": "Таймаут (с):", + "value_source.game_event.timeout.hint": "Секунды тишины до возврата к значению по умолчанию.", + "value_source.group.title": "Источники значений", + "value_source.http.endpoint": "HTTP-эндпоинт:", + "value_source.http.endpoint.hint": "Выберите сохранённый эндпоинт во вкладке HTTP-интеграций.", + "value_source.http.endpoint_required": "Требуется HTTP-эндпоинт", + "value_source.http.interval": "Интервал (с):", + "value_source.http.interval.hint": "Период опроса в секундах. Несколько источников могут использовать один эндпоинт с разными интервалами.", + "value_source.http.interval_invalid": "Интервал должен быть не меньше 1 секунды", + "value_source.http.json_path": "JSON-путь:", + "value_source.http.json_path.hint": "Пусто = использовать необработанное тело ответа. Используйте путь с точками и индексами, например MediaContainer.Metadata[0].title", + "value_source.http.max_value": "Макс. значение:", + "value_source.http.max_value.hint": "Извлечённое значение, которое отображается в выход 1.0 (для нормализации).", + "value_source.http.min_value": "Мин. значение:", + "value_source.http.min_value.hint": "Извлечённое значение, которое отображается в выход 0.0 (для нормализации).", + "value_source.http.modulator.hint": "Используется только когда этот источник управляет яркостью или цветом. Правила автоматизации читают извлечённое значение в исходном виде и игнорируют эти настройки.", + "value_source.http.modulator.summary": "Сопоставление для модулятора (необязательно)", + "value_source.max_value": "Макс. значение:", + "value_source.max_value.hint": "Максимальный выход цикла волны", + "value_source.min_value": "Мин. значение:", + "value_source.min_value.hint": "Минимальный выход цикла волны", + "value_source.mode": "Режим:", + "value_source.mode.beat": "Бит", + "value_source.mode.beat.desc": "Детекция ритмических ударов", + "value_source.mode.hint": "RMS измеряет среднюю громкость. Пик отслеживает самые громкие моменты. Бит реагирует на ритм.", + "value_source.mode.peak": "Пик", + "value_source.mode.peak.desc": "Отслеживание пиковых моментов", + "value_source.mode.rms": "RMS (Громкость)", + "value_source.mode.rms.desc": "Средний уровень громкости", + "value_source.name": "Название:", + "value_source.name.hint": "Описательное имя для этого источника значений", + "value_source.name.placeholder": "Пульс яркости", + "value_source.normalize": "Нормализовать в 0–1:", + "value_source.normalize.hint": "Вкл.: масштабировать сырое значение в 0–1 по Min/Max. Выкл.: значение ограничивается диапазоном 0–1 как есть (для источников, уже выдающих долю 0–1). Сырое значение остаётся доступным в шаблонах (raw[name]) и автоматизациях.", + "value_source.picture_source": "Источник изображения:", + "value_source.picture_source.hint": "Источник изображения, кадры которого будут анализироваться на среднюю яркость.", + "value_source.scene_behavior": "Поведение:", + "value_source.scene_behavior.complement": "Дополнение (тёмный → ярко)", + "value_source.scene_behavior.hint": "Дополнение: тёмная сцена = высокая яркость (для фоновой подсветки). Совпадение: яркая сцена = высокая яркость.", + "value_source.scene_behavior.match": "Совпадение (яркий → ярко)", + "value_source.scene_sensitivity.hint": "Множитель усиления сигнала яркости (выше = более чувствительный к изменениям яркости)", + "value_source.schedule": "Расписание:", + "value_source.schedule.add": "+ Добавить точку", + "value_source.schedule.hint": "Определите минимум 2 временные точки. Яркость линейно интерполируется между ними, с переходом через полночь.", + "value_source.schedule.points": "точек", + "value_source.select_type": "Выберите тип источника значений", + "value_source.sensitivity": "Чувствительность:", + "value_source.sensitivity.hint": "Множитель усиления аудиосигнала (выше = более реактивный)", + "value_source.smoothing": "Сглаживание:", + "value_source.smoothing.hint": "Временное сглаживание (0 = мгновенный отклик, 1 = очень плавный/медленный)", + "value_source.speed": "Скорость (цикл/мин):", + "value_source.speed.hint": "Циклов в минуту — как быстро повторяется волна (1 = очень медленно, 120 = очень быстро)", + "value_source.template.add_input": "+ Добавить вход", + "value_source.template.default_value": "Значение по умолчанию:", + "value_source.template.default_value.hint": "Значение на выходе, когда выражение нельзя вычислить (например, отсутствует вход).", + "value_source.template.error.cycle": "Это выражение создало бы циклическую зависимость", + "value_source.template.error.duplicate_name": "Повторяющееся имя входа", + "value_source.template.error.invalid_expr": "Некорректное выражение", + "value_source.template.error.invalid_name": "Некорректное имя переменной", + "value_source.template.error.missing_input": "Каждому входу нужно имя переменной", + "value_source.template.error.reserved_name": "Зарезервированное имя нельзя использовать как вход", + "value_source.template.error.unbound": "Выражение ссылается на непривязанную переменную", + "value_source.template.eval_interval": "Интервал вычисления (с):", + "value_source.template.eval_interval.hint": "Как часто пересчитывать выражение. 0 = при каждом опросе (так быстро, как обновляются входы).", + "value_source.template.expression": "Выражение:", + "value_source.template.expression.hint": "Изолированное выражение Jinja, возвращающее число. Привязанные входы доступны по имени; используйте raw[name] для ненормализованного значения.", + "value_source.template.expression.placeholder": "clamp((temp - 18) / 10, 0, 1)", + "value_source.template.hints.examples_title": "Примеры", + "value_source.template.hints.globals": "min(a, b), max(a, b), abs(x), round(x), clamp(x, lo, hi)", + "value_source.template.hints.globals_title": "Глобальные функции", + "value_source.template.hints.inputs_title": "Привязанные входы", + "value_source.template.hints.no_inputs": "Входы ещё не привязаны", + "value_source.template.hints.raw": "raw[name] даёт ненормализованное значение входа, если оно есть.", + "value_source.template.hints.raw_title": "Исходные значения", + "value_source.template.hints.time": "Совет: для логики времени суток привяжите источник «Адаптивный (время)» или «Дневной цикл» как вход.", + "value_source.template.hints.title": "Справка по выражению", + "value_source.template.input_count": "входов", + "value_source.template.input_count_one": "вход", + "value_source.template.input_name": "имя переменной", + "value_source.template.inputs": "Входы:", + "value_source.template.inputs.empty": "Пока нет входов. Нажмите +, чтобы привязать источник значений.", + "value_source.template.inputs.hint": "Привяжите числовые источники значений к именам переменных, используемым в выражении.", + "value_source.template.valid": "Выражение корректно", + "value_source.test": "Тест", + "value_source.test.connecting": "Подключение...", + "value_source.test.current": "Текущее", + "value_source.test.error": "Не удалось подключиться", + "value_source.test.max": "Макс", + "value_source.test.min": "Мин", + "value_source.test.title": "Тест источника значений", + "value_source.type": "Тип:", + "value_source.type.adaptive_scene": "Адаптивный (Сцена)", + "value_source.type.adaptive_scene.desc": "Подстройка по содержимому сцены", + "value_source.type.adaptive_time": "Адаптивный (Время)", + "value_source.type.adaptive_time.desc": "Подстройка по времени суток", + "value_source.type.animated": "Анимированный", + "value_source.type.animated.desc": "Циклическая смена по форме волны", + "value_source.type.audio": "Аудио", + "value_source.type.audio.desc": "Реагирует на звуковой сигнал", + "value_source.type.daylight": "Дневной цикл", + "value_source.type.daylight.desc": "Значение следует циклу дня/ночи", + "value_source.type.game_event": "Игровое событие", + "value_source.type.game_event.desc": "Игровые метрики (здоровье, патроны, мана) как значения 0-1", + "value_source.type.hint": "Статический выдаёт постоянное значение. Анимированный циклически меняет форму волны. Аудио реагирует на звук. Адаптивные типы автоматически подстраивают яркость по времени суток или содержимому сцены.", + "value_source.type.http": "HTTP-опрос", + "value_source.type.http.desc": "Периодически опрашивает HTTP-эндпоинт и сопоставляет извлечённое значение с диапазоном 0–1.", + "value_source.type.static": "Статический", + "value_source.type.static.desc": "Постоянное выходное значение", + "value_source.type.template": "Шаблон Jinja", + "value_source.type.template.desc": "Объедините привязанные входы в изолированном выражении Jinja для вычисления значения 0-1.", + "value_source.updated": "Источник значений обновлён", + "value_source.value": "Значение:", + "value_source.value.hint": "Постоянное выходное значение (0.0 = выкл, 1.0 = полная яркость)", + "value_source.waveform": "Форма волны:", + "value_source.waveform.hint": "Форма цикла анимации яркости", + "value_source.waveform.sawtooth": "Пила", + "value_source.waveform.sine": "Синус", + "value_source.waveform.square": "Прямоугольник", + "value_source.waveform.triangle": "Треугольник", + "weather_source.add": "Добавить источник погоды", + "weather_source.created": "Источник погоды создан", + "weather_source.delete.confirm": "Удалить этот источник погоды? Связанные источники цветовых лент потеряют данные о погоде.", + "weather_source.deleted": "Источник погоды удалён", + "weather_source.description": "Описание (необязательно):", + "weather_source.description.placeholder": "Необязательное описание", + "weather_source.edit": "Редактировать источник погоды", + "weather_source.error.load": "Не удалось загрузить источник погоды", + "weather_source.error.name_required": "Название источника погоды обязательно", + "weather_source.geo.error": "Ошибка геолокации", + "weather_source.geo.not_supported": "Геолокация не поддерживается вашим браузером", + "weather_source.geo.success": "Местоположение определено", + "weather_source.group.title": "Источники погоды", + "weather_source.latitude": "Шир:", + "weather_source.location": "Местоположение:", + "weather_source.location.hint": "Географические координаты. Используйте автоопределение или введите вручную.", + "weather_source.longitude": "Долг:", + "weather_source.name": "Название:", + "weather_source.name.hint": "Описательное название источника погоды", + "weather_source.name.placeholder": "Моя погода", + "weather_source.provider": "Провайдер:", + "weather_source.provider.hint": "Провайдер метеоданных. Open-Meteo бесплатный и не требует API ключа.", + "weather_source.provider.open_meteo.desc": "Бесплатно, без API ключа", + "weather_source.test": "Тест", + "weather_source.update_interval": "Интервал обновления:", + "weather_source.update_interval.hint": "Частота запроса метеоданных. Меньше = более оперативные обновления.", + "weather_source.updated": "Источник погоды обновлён", + "weather_source.use_my_location": "Определить", + "weekday.short.0": "Пн", + "weekday.short.1": "Вт", + "weekday.short.2": "Ср", + "weekday.short.3": "Чт", + "weekday.short.4": "Пт", + "weekday.short.5": "Сб", + "weekday.short.6": "Вс", + "wizard.calibrate.desc": "Укажите, где начинается лента и как она проходит вокруг экрана.", + "wizard.calibrate.skip": "Пропустить калибровку", + "wizard.calibrate.title": "Калибровка ленты", + "wizard.device.desc": "Выполните сканирование сети или добавьте устройство вручную.", + "wizard.device.discovered": "Найдено в сети", + "wizard.device.existing": "Существующие устройства", + "wizard.device.manual.add": "Добавить устройство", + "wizard.device.manual.led_count": "Количество светодиодов", + "wizard.device.manual.name": "Имя устройства", + "wizard.device.manual.name_placeholder": "Моя LED-лента", + "wizard.device.manual.title": "Добавить вручную", + "wizard.device.manual.url": "Адрес устройства", + "wizard.device.none_found": "Устройства не найдены. Попробуйте добавить вручную.", + "wizard.device.rescan": "Повторить", + "wizard.device.scanning": "Сканирование сети…", + "wizard.device.title": "Найдите устройство", + "wizard.display.confirm": "Использовать этот экран", + "wizard.display.desc": "Укажите монитор для захвата подсветки.", + "wizard.display.index_prefix": "Дисплей", + "wizard.display.loading": "Загрузка дисплеев…", + "wizard.display.manual_index": "Индекс дисплея", + "wizard.display.no_displays": "Дисплеи не найдены. Введите индекс вручную.", + "wizard.display.primary": "Основной", + "wizard.display.title": "Выберите экран", + "wizard.done.desc": "Ваша подсветка активна. Наслаждайтесь!", + "wizard.done.device": "Устройство", + "wizard.done.display": "Экран", + "wizard.done.finish": "Завершить", + "wizard.done.title": "Готово!", + "wizard.error.device_create_failed": "Не удалось создать устройство.", + "wizard.error.device_name_required": "Введите имя устройства.", + "wizard.error.device_url_required": "Введите адрес устройства.", + "wizard.error.no_device": "Сначала выберите или добавьте устройство.", + "wizard.error.scaffold_failed": "Ошибка настройки. Попробуйте ещё раз.", + "wizard.error.start_failed": "Не удалось запустить LED-вывод.", + "wizard.modal.title": "Мастер настройки", + "wizard.rerun": "Запустить мастер настройки заново", + "wizard.scaffold.building": "Создание объектов…", + "wizard.scaffold.desc": "Создаём цепочку захвата: экран → цветовая лента → LED-выход.", + "wizard.scaffold.done": "Конфигурация создана! Готово к калибровке.", + "wizard.scaffold.title": "Создание конфигурации", + "wizard.skip": "Пропустить", + "wizard.start": "Начать", + "wizard.start.done": "LED-вывод работает!", + "wizard.start.failed": "Не удалось запустить. Запустите вручную на вкладке «Цели».", + "wizard.start.starting": "Запуск LED-вывода…", + "wizard.start.title": "Запуск вывода", + "wizard.step.calibrate": "Калибровка", + "wizard.step.device": "Устройство", + "wizard.step.display": "Экран", + "wizard.step.done": "Готово", + "wizard.step.scaffold": "Настройка", + "wizard.step.start": "Запуск", + "wizard.step.welcome": "Добро пожаловать", + "wizard.welcome.desc": "Настроим вашу LED-ленту за несколько шагов.", + "wizard.welcome.item1": "Подключите контроллер LED", + "wizard.welcome.item2": "Выберите экран для захвата", + "wizard.welcome.item3": "Откалибруйте расположение ленты", + "wizard.welcome.item4": "Запустите подсветку", + "wizard.welcome.title": "Добро пожаловать в LED Grab" } diff --git a/server/src/ledgrab/static/locales/zh.json b/server/src/ledgrab/static/locales/zh.json index 66097cb..82110d2 100644 --- a/server/src/ledgrab/static/locales/zh.json +++ b/server/src/ledgrab/static/locales/zh.json @@ -1,2984 +1,3140 @@ { - "app.title": "LED Grab", - "bindable.none": "无(静态值)", - "bindable.toggle": "切换值源绑定", - "ha_light.color_tolerance": "色彩容差:", - "ha_light.color_tolerance.hint": "当RGB差异低于此阈值时跳过颜色更新,减少HA流量。", - "ha_light.min_brightness_threshold": "最低亮度阈值:", - "ha_light.min_brightness_threshold.hint": "有效输出亮度低于此值时完全关灯(0=禁用)。", - "ha_light.stop_action": "停止时:", - "ha_light.stop_action.hint": "此目标停止流式传输时对映射的灯执行的操作。", - "ha_light.stop_action.none": "无", - "ha_light.stop_action.none.desc": "保持灯的当前状态", - "ha_light.stop_action.turn_off": "关闭", - "ha_light.stop_action.turn_off.desc": "关闭所有映射的灯", - "ha_light.stop_action.restore": "恢复", - "ha_light.stop_action.restore.desc": "恢复到启动时捕获的状态", - "ha_light.button.turn_off": "关闭灯具", - "ha_light.turn_off.success": "灯具已关闭", - "ha_light.turn_off.failed": "关闭灯具失败", - "confirm.turn_off_ha_light": "关闭所有映射的灯具?", - "ha_light.connection_tooltip": "HA 连接", - "ha_light.connection_metric": "Home Assistant 连接", - "ha_light.lights.one": "{count} 盏灯", - "ha_light.lights.other": "{count} 盏灯", - "ha_light.color_source": "颜色源:", - "ha_light.color_source.hint": "选择颜色条源(每个灯具的 LED 范围)或颜色值源(一种颜色广播到所有灯具)。", - "ha_light.color_source.css": "颜色条", - "ha_light.color_source.color_vs": "颜色值源", - "ha_light.mappings.color_vs_hint": "所有列出的灯具都将接收来自所选颜色值源的相同颜色。", - "ha_light.mapping.unassigned": "— 未选择实体", - "ha_light.error.color_source_required": "广播单一颜色时必须选择颜色值源", - "ha_light.section.title": "Home Assistant", - "ha_light.section.targets": "灯光目标", - "ha_light.add": "添加 HA 灯光目标", - "ha_light.edit": "编辑 HA 灯光目标", - "ha_light.created": "HA 灯光目标已创建", - "ha_light.updated": "HA 灯光目标已更新", - "ha_light.name": "名称:", - "ha_light.name.placeholder": "客厅灯光", - "ha_light.description": "描述(可选):", - "ha_light.ha_source": "HA 连接:", - "ha_light.css_source": "颜色条源:", - "ha_light.update_rate": "更新频率:", - "ha_light.update_rate.hint": "向 HA 灯光发送颜色更新的频率(0.5-5.0 Hz)。较低值对 HA 性能更安全。", - "ha_light.transition": "过渡:", - "ha_light.transition.hint": "颜色之间的平滑过渡时长(HA transition 参数)。", - "ha_light.mappings": "灯光映射:", - "ha_light.mappings.add": "添加映射", - "ha_light.mappings.hint": "将 LED 范围映射到 HA 灯光实体。每个映射将 LED 段平均为单一颜色。", - "ha_light.mapping.entity_id": "实体 ID:", - "ha_light.mapping.led_start": "LED 开始:", - "ha_light.mapping.led_end": "LED 结束(-1=最后):", - "ha_light.mapping.brightness": "亮度比例:", - "ha_light.mapping.search_entity": "搜索灯光实体...", - "ha_light.mapping.select_entity": "选择一个灯光实体...", - "ha_light.error.name_required": "需要名称", - "ha_light.error.ha_source_required": "需要 HA 连接", - "app.version": "版本:", - "app.api_docs": "API 文档", - "app.connection_lost": "服务器不可达", - "app.connection_retrying": "正在尝试重新连接…", - "app.server_restarting": "服务器正在重启…", - "app.server_restarting_sub": "请稍候,服务器即将恢复。", - "demo.badge": "演示", - "demo.banner": "您正处于演示模式 — 所有设备和数据均为虚拟。未使用任何真实硬件。", - "theme.toggle": "切换主题", - "bg.anim.toggle": "切换动态背景", - "accent.title": "主题色", - "accent.custom": "自定义", - "accent.reset": "重置", - "locale.change": "切换语言", - "auth.login": "登录", - "auth.logout": "退出", - "auth.authenticated": "● 已认证", - "auth.title": "登录 LED Grab", - "auth.message": "请输入 API 密钥以进行身份验证并访问 LED Grab。", - "auth.label": "API 密钥:", - "auth.placeholder": "输入您的 API 密钥...", - "auth.hint": "API 密钥将安全存储在浏览器的本地存储中。", - "auth.button.cancel": "取消", - "auth.button.login": "登录", - "auth.error.required": "请输入 API 密钥", - "auth.error.invalid": "API 密钥无效,请重试。", - "auth.success": "登录成功!", - "auth.logout.confirm": "确定要退出登录吗?", - "auth.logout.success": "已成功退出", - "auth.please_login": "请先登录", - "auth.session_expired": "会话已过期或 API 密钥无效,请重新登录。", - "auth.toggle_password": "切换密码可见性", - "auth.prompt_enter": "Enter your API key:", - "auth.prompt_update": "Current API key is set. Enter new key to update or leave blank to remove:", - "api_key.login": "登录", - "setup.title": "服务器需要配置", - "setup.description": "此 LedGrab 服务器未配置 API 密钥,出于安全考虑已禁用来自网络其他设备的访问。请在运行服务器的机器上配置密钥以启用局域网访问。", - "setup.step1_label": "1. 在服务器所在机器上,编辑 config/default_config.yaml:", - "setup.step2_label": "2. 重启服务器,然后刷新此页面并使用该密钥登录。", - "setup.step3_label": "或者:直接在服务器本机打开 LedGrab(回环地址),无需密钥:", - "setup.hint_openssl": "生成强密钥:Linux/macOS 使用 openssl rand -hex 32;Windows PowerShell 使用 [guid]::NewGuid().ToString('N') + [guid]::NewGuid().ToString('N')。", - "setup.copy": "复制代码片段", - "setup.copied": "已复制到剪贴板", - "setup.retry": "我已配置密钥 — 重试", - "setup.still_required": "服务器仍报告未配置 API 密钥。请确认已保存配置文件并重启服务器。", - "displays.title": "可用显示器", - "displays.layout": "显示器", - "displays.information": "显示器信息", - "displays.legend.primary": "主显示器", - "displays.legend.secondary": "副显示器", - "displays.badge.primary": "主", - "displays.badge.secondary": "副", - "displays.resolution": "分辨率:", - "displays.refresh_rate": "刷新率:", - "displays.position": "位置:", - "displays.index": "显示器序号:", - "displays.loading": "正在加载显示器...", - "displays.none": "没有可用的显示器", - "displays.failed": "加载显示器失败", - "displays.picker.title": "选择显示器", - "displays.picker.title.lead": "选择", - "displays.picker.title.accent": "显示器", - "displays.picker.eyebrow.label": "来源", - "displays.picker.eyebrow.channel": "显示器 · 布局", - "displays.picker.eyebrow.channel.device": "设备 · 列表", - "displays.picker.foot.dismiss": "关闭", - "displays.picker.foot.select": "选择显示器", - "displays.picker.foot.select.device": "选择设备", - "displays.picker.select": "选择显示器...", - "displays.picker.click_to_select": "点击选择此显示器", - "displays.picker.adb_connect": "连接 ADB 设备", - "displays.picker.adb_connect.placeholder": "IP 地址(例如 192.168.2.201)", - "displays.picker.adb_connect.button": "连接", - "displays.picker.adb_connect.success": "设备已连接", - "displays.picker.adb_connect.error": "连接设备失败", - "displays.picker.adb_disconnect": "断开连接", - "displays.picker.no_android": "未找到 Android 设备。请通过 USB 连接或在上方输入 IP 地址。", - "templates.title": "引擎模板", - "templates.description": "采集模板定义屏幕的采集方式。每个模板使用特定的采集引擎(MSS、DXcam、WGC)及自定义设置。将模板分配给设备以获得最佳性能。", - "templates.loading": "正在加载模板...", - "templates.empty": "尚未配置采集模板", - "templates.add": "添加引擎模板", - "templates.edit": "编辑引擎模板", - "templates.name": "模板名称:", - "templates.name.placeholder": "我的自定义模板", - "templates.description.label": "描述(可选):", - "templates.description.placeholder": "描述此模板...", - "templates.engine": "采集引擎:", - "templates.engine.hint": "选择要使用的屏幕采集技术", - "templates.engine.select": "选择引擎...", - "templates.engine.unavailable": "不可用", - "templates.engine.unavailable.hint": "此引擎在您的系统上不可用", - "templates.engine.mss.desc": "跨平台,纯Python", - "templates.engine.dxcam.desc": "DirectX,低延迟", - "templates.engine.bettercam.desc": "DirectX,高性能", - "templates.engine.camera.desc": "USB/IP摄像头捕获", - "templates.engine.scrcpy.desc": "Android屏幕镜像 (adb screencap)", - "templates.engine.scrcpy_client.desc": "Android H.264流,高帧率", - "templates.engine.wgc.desc": "Windows图形捕获", - "templates.engine.demo.desc": "动画测试图案(演示模式)", - "templates.engine.mediaprojection.desc": "原生Android屏幕捕获", - "templates.engine.android_camera.desc": "设备摄像头捕获 (Camera2)", - "templates.config": "配置", - "templates.config.show": "显示配置", - "templates.config.none": "无额外配置", - "templates.config.default": "默认", - "templates.config.camera_backend.auto": "自动检测最佳后端", - "templates.config.camera_backend.dshow": "Windows DirectShow", - "templates.config.camera_backend.msmf": "Windows Media Foundation", - "templates.config.camera_backend.v4l2": "Linux Video4Linux2", - "templates.config.resolution.auto": "以摄像头支持的最高模式打开", - "templates.config.resolution.480p": "VGA — CPU 与带宽最低", - "templates.config.resolution.720p": "HD", - "templates.config.resolution.1080p": "Full HD", - "templates.config.resolution.1440p": "QHD / 2K", - "templates.config.resolution.2160p": "4K UHD — CPU 与带宽最高", - "templates.created": "模板创建成功", - "templates.updated": "模板更新成功", - "templates.deleted": "模板删除成功", - "templates.delete.confirm": "确定要删除此模板吗?", - "templates.error.load": "加载模板失败", - "templates.error.engines": "加载引擎失败", - "templates.error.required": "请填写所有必填项", - "templates.error.delete": "删除模板失败", - "templates.error.save_failed": "保存模板失败", - "templates.error.load_failed": "加载模板失败", - "templates.test.title": "测试采集", - "templates.test.description": "保存前测试此模板,查看采集预览和性能指标。", - "templates.test.display": "显示器:", - "templates.test.display.select": "选择显示器...", - "templates.test.device": "设备:", - "templates.test.device.select": "选择设备...", - "templates.test.duration": "采集时长(秒):", - "templates.test.border_width": "边框宽度(像素):", - "templates.test.run": "运行", - "templates.test.running": "正在运行测试...", - "templates.test.results.preview": "全幅采集预览", - "templates.test.results.borders": "边框提取", - "templates.test.results.top": "上", - "templates.test.results.right": "右", - "templates.test.results.bottom": "下", - "templates.test.results.left": "左", - "templates.test.results.performance": "性能", - "templates.test.results.capture_time": "采集", - "templates.test.results.extraction_time": "提取", - "templates.test.results.total_time": "总计", - "templates.test.results.max_fps": "最大 FPS", - "templates.test.results.duration": "时长", - "templates.test.results.frame_count": "帧数", - "templates.test.results.actual_fps": "实际 FPS", - "templates.test.results.avg_capture_time": "平均采集", - "templates.test.results.resolution": "分辨率:", - "templates.test.error.no_engine": "请选择采集引擎", - "templates.test.error.no_display": "请选择显示器", - "templates.test.error.no_device": "请选择设备", - "templates.test.error.failed": "测试失败", - "devices.title": "设备", - "device.select_type": "选择设备类型", - "devices.add": "添加新设备", - "devices.loading": "正在加载设备...", - "devices.none": "尚未配置设备", - "devices.failed": "加载设备失败", - "devices.wled_config": "WLED 配置:", - "devices.wled_note": "使用以下方式配置您的 WLED 设备(效果、分段、颜色顺序、功率限制等):", - "devices.wled_link": "官方 WLED 应用", - "devices.wled_note_or": "或内置的", - "devices.wled_webui_link": "WLED Web UI", - "devices.wled_note_webui": "(在浏览器中打开设备 IP 地址)。", - "devices.wled_note2": "此控制器发送像素颜色数据并控制每个设备的亮度。", - "device.scan": "自动发现", - "device.scan.empty": "未找到设备", - "device.scan.error": "网络扫描失败", - "device.scan.already_added": "已添加", - "device.scan.selected": "设备已选择", - "device.type": "设备类型:", - "device.type.hint": "选择 LED 控制器的类型", - "device.type.wled": "WLED", - "device.type.wled.desc": "通过HTTP/UDP控制的WiFi LED", - "device.type.adalight": "Adalight", - "device.type.adalight.desc": "Arduino串口LED协议", - "device.type.ambiled": "AmbiLED", - "device.type.ambiled.desc": "AmbiLED串口协议", - "device.type.mqtt": "MQTT", - "device.type.mqtt.desc": "通过MQTT代理发布LED数据", - "device.type.ws": "WebSocket", - "device.type.ws.desc": "通过WebSocket流式传输LED数据", - "device.type.openrgb": "OpenRGB", - "device.type.openrgb.desc": "通过OpenRGB控制RGB外设", - "device.type.dmx": "DMX", - "device.type.dmx.desc": "Art-Net / sACN (E1.31) 舞台灯光", - "device.type.ddp": "DDP", - "device.type.ddp.desc": "直接UDP像素推送 (Pixelblaze、ESPixelStick、Falcon)", - "device.type.opc": "OPC", - "device.type.opc.desc": "Open Pixel Control (Fadecandy、xLights、爱好者驱动)", - "device.type.mock": "Mock", - "device.type.mock.desc": "用于测试的虚拟设备", - "device.type.espnow": "ESP-NOW", - "device.type.espnow.desc": "Ultra-low-latency via ESP32 gateway", - "device.type.hue": "Philips Hue", - "device.type.hue.desc": "Hue Entertainment API streaming", - "device.type.yeelight": "Yeelight", - "device.type.yeelight.desc": "通过局域网连接小米智能灯泡/灯带(单色,由灯带颜色平均得出)", - "device.yeelight.url": "IP 地址:", - "device.yeelight.url.hint": "Yeelight 灯泡的局域网 IP。协议固定使用 TCP 端口 55443。", - "device.yeelight.url.placeholder": "192.168.1.50", - "device.yeelight_min_interval": "最小更新间隔:", - "device.yeelight_min_interval.hint": "客户端命令速率限制(毫秒)。默认 500 毫秒可使灯泡保持在约 1 cmd/sec 限制下;较低的值可能导致节流。", - "device.type.wiz": "WiZ", - "device.type.wiz.desc": "WiZ Connected (飞利浦) UDP 局域网灯泡", - "device.wiz.url": "IP 地址:", - "device.wiz.url.hint": "WiZ 灯泡的局域网 IP。UDP 端口 38899 为协议默认值。", - "device.wiz.url.placeholder": "192.168.1.50", - "device.wiz_min_interval": "最小更新间隔:", - "device.wiz_min_interval.hint": "客户端命令速率限制(毫秒)。UDP 即发即忘可处理快速更新;默认 50 毫秒 ≈ 20 Hz。", - "device.type.lifx": "LIFX", - "device.type.lifx.desc": "通过局域网连接 LIFX 智能灯泡/灯带", - "device.lifx.url": "IP 地址:", - "device.lifx.url.hint": "LIFX 灯泡的局域网 IP。UDP 端口 56700 为协议默认值。", - "device.lifx.url.placeholder": "192.168.1.50", - "device.lifx_min_interval": "最小更新间隔:", - "device.lifx_min_interval.hint": "客户端命令速率限制(毫秒)。LIFX 建议 ≤20 cmd/sec;默认 50 毫秒符合该上限。", - "device.type.govee": "Govee", - "device.type.govee.desc": "通过 LAN API 连接 Govee Wi-Fi 灯泡/氛围套件", - "device.govee.url": "IP 地址:", - "device.govee.url.hint": "Govee 设备的局域网 IP。请先在 Govee Home 应用中启用 LAN Control(设备 → ⚙ → LAN Control),否则灯泡不会响应。", - "device.govee.url.placeholder": "192.168.1.50", - "device.govee_min_interval": "最小更新间隔:", - "device.govee_min_interval.hint": "客户端命令速率限制(毫秒)。UDP 即发即忘可处理快速更新;默认 50 毫秒 ≈ 20 Hz。", - "device.type.nanoleaf": "Nanoleaf", - "device.type.nanoleaf.desc": "Nanoleaf Light Panels / Canvas / Shapes / Lines / Elements(HTTP REST,需要配对)", - "device.nanoleaf.url": "IP 地址:", - "device.nanoleaf.url.hint": "Nanoleaf 控制器的局域网 IP。HTTP 端口 16021 由协议固定。", - "device.nanoleaf.url.placeholder": "192.168.1.50", - "device.nanoleaf_min_interval": "最小更新间隔:", - "device.nanoleaf_min_interval.hint": "客户端命令速率限制(毫秒)。默认 100 毫秒 ≈ 10 Hz;HTTP 请求开销将实际上限限制在约 20 Hz。", - "device.nanoleaf.pair.instructions": "请按住 Nanoleaf 控制器上的电源按钮 5 秒,直到 LED 闪烁,然后点击「开始」。控制器将打开 30 秒的配对窗口。", - "device.nanoleaf.pair_button": "配对设备", - "device.nanoleaf.token.label": "认证令牌:", - "device.nanoleaf.paired": "已配对", - "device.type.ble": "BLE LED 控制器", - "device.type.ble.desc": "Bluetooth LE 灯带:SP110E、Triones、Zengge、Govee(整条灯带同色)", - "device.ble.url": "BLE 地址:", - "device.ble.url.hint": "MAC 地址(Windows/Linux)或 UUID(macOS),加前缀 ble://", - "device.ble.family": "协议:", - "device.ble.family.hint": "控制器使用哪种 BLE 协议。按你平时使用的手机应用选择。", - "device.ble.family.sp110e.desc": "可编址控制器 — LED Hue / SP110E 应用", - "device.ble.family.triones.desc": "单色控制器 — HappyLighting / LEDnet", - "device.ble.family.zengge.desc": "单色控制器 — iLightsIn / Mohuan", - "device.ble.family.govee.desc": "Govee H6xxx 灯带 — 仅支持未加密固件", - "device.ble.govee_key": "Govee AES 密钥(hex):", - "device.ble.govee_key.hint": "可选。新版 Govee 固件需要按型号的 AES 密钥 — 老固件留空即可。", - "device.ble.govee_key.placeholder": "32位十六进制,如 0102…1f20", - "device.type.usbhid": "USB HID", - "device.type.usbhid.desc": "USB RGB peripherals (keyboards, mice)", - "device.type.spi": "SPI Direct", - "device.type.spi.desc": "Raspberry Pi GPIO/SPI LED strips", - "device.type.chroma": "Razer Chroma", - "device.type.chroma.desc": "Razer peripherals via Chroma SDK", - "device.type.gamesense": "SteelSeries", - "device.type.gamesense.desc": "SteelSeries peripherals via GameSense", - "device.type.group": "设备组", - "device.type.group.desc": "将多个设备组合为一个虚拟设备", - "device.group.children": "子设备:", - "device.group.children.hint": "选择要包含在组中的设备。顺序在序列模式下很重要。", - "device.group.add_child": "+ 添加设备", - "device.group.select_device": "选择设备", - "device.group.mode": "组模式:", - "device.group.mode.hint": "序列模式将LED首尾相连。独立模式将完整灯带镜像到每个设备。", - "device.group.mode.sequence": "序列", - "device.group.mode.sequence.desc": "将LED首尾相连合并为一条长灯带", - "device.group.mode.independent": "独立", - "device.group.mode.independent.desc": "将完整灯带独立镜像到每个设备", - "device.group.move_up": "上移", - "device.group.move_down": "下移", - "device.group.error.no_children": "请至少添加一个子设备到组中。", - "device.chroma.device_type": "Peripheral Type:", - "device.chroma.device_type.hint": "Which Razer peripheral to control via Chroma SDK", - "device.gamesense.device_type": "Peripheral Type:", - "device.gamesense.device_type.hint": "Which SteelSeries peripheral to control via GameSense", - "device.espnow.peer_mac": "Peer MAC:", - "device.espnow.peer_mac.hint": "MAC address of the remote ESP32 receiver (e.g. AA:BB:CC:DD:EE:FF)", - "device.espnow.channel": "WiFi Channel:", - "device.espnow.channel.hint": "WiFi channel (1-14). Must match the receiver's channel.", - "device.hue.url": "Bridge IP:", - "device.hue.url.hint": "IP address of your Hue bridge", - "device.hue.username": "Bridge Username:", - "device.hue.username.hint": "Hue bridge application key from pairing", - "device.hue.client_key": "Client Key:", - "device.hue.client_key.hint": "Entertainment API client key (hex string from pairing)", - "device.hue.group_id": "Entertainment Group:", - "device.hue.group_id.hint": "Entertainment configuration ID from your Hue bridge", - "device.usbhid.url": "VID:PID:", - "device.usbhid.url.hint": "USB Vendor:Product ID in hex (e.g. 1532:0084)", - "device.spi.url": "GPIO/SPI Path:", - "device.spi.url.hint": "GPIO pin or SPI device path (e.g. spi://gpio:18)", - "device.spi.speed": "SPI Speed (Hz):", - "device.spi.speed.hint": "SPI clock speed. 800000 Hz for WS2812, 2400000 Hz for APA102.", - "device.spi.led_type": "LED Chipset:", - "device.spi.led_type.hint": "Type of addressable LED strip connected to the GPIO/SPI pin", - "device.spi.led_type.ws2812b.desc": "Most common, 800 KHz data, 3-wire RGB", - "device.spi.led_type.ws2812.desc": "Original WS2812, 800 KHz, 3-wire RGB", - "device.spi.led_type.ws2811.desc": "External driver IC, 400 KHz, 12V strips", - "device.spi.led_type.sk6812.desc": "Samsung LED, 800 KHz, 3-wire RGB", - "device.spi.led_type.sk6812_rgbw.desc": "SK6812 with dedicated white channel", - "device.gamesense.peripheral.keyboard": "Keyboard", - "device.gamesense.peripheral.keyboard.desc": "Per-key RGB illumination", - "device.gamesense.peripheral.mouse": "Mouse", - "device.gamesense.peripheral.mouse.desc": "Mouse RGB zones", - "device.gamesense.peripheral.headset": "Headset", - "device.gamesense.peripheral.headset.desc": "Headset earcup lighting", - "device.gamesense.peripheral.mousepad": "Mousepad", - "device.gamesense.peripheral.mousepad.desc": "Mousepad edge lighting zones", - "device.gamesense.peripheral.indicator": "Indicator", - "device.gamesense.peripheral.indicator.desc": "OLED/LED status indicator", - "device.dmx_protocol": "DMX 协议:", - "device.dmx_protocol.hint": "Art-Net 使用 UDP 端口 6454,sACN (E1.31) 使用 UDP 端口 5568", - "device.dmx_protocol.artnet.desc": "UDP 单播,端口 6454", - "device.dmx_protocol.sacn.desc": "组播/单播,端口 5568", - "device.dmx_start_universe": "起始 Universe:", - "device.dmx_start_universe.hint": "第一个 DMX universe (0-32767)。超过 170 个 LED 时自动使用多个 universe。", - "device.dmx_start_channel": "起始通道:", - "device.dmx_start_channel.hint": "universe 中的第一个 DMX 通道 (1-512)", - "device.dmx.url": "IP 地址:", - "device.dmx.url.hint": "DMX 节点的 IP 地址(例如 192.168.1.50)", - "device.dmx.url.placeholder": "192.168.1.50", - "device.ddp.url": "IP 地址:", - "device.ddp.url.hint": "DDP 接收器地址。端口默认为 4048。", - "device.ddp.url.placeholder": "192.168.1.50", - "device.ddp_port": "DDP 端口:", - "device.ddp_port.hint": "UDP 端口(0 = 协议默认值 4048)。", - "device.ddp_destination_id": "目标 ID:", - "device.ddp_destination_id.hint": "DDP 目标标识符(1 = 显示)。", - "device.ddp_color_order": "颜色顺序:", - "device.ddp_color_order.hint": "线路上的通道字节顺序。大多数 DDP 接收器期望 RGB。", - "device.ddp.color_order.rgb.desc": "Standard RGB byte order", - "device.ddp.color_order.grb.desc": "WS2812/WS2812B native order", - "device.ddp.color_order.brg.desc": "BRG byte order", - "device.ddp.color_order.rbg.desc": "RBG byte order", - "device.ddp.color_order.bgr.desc": "BGR byte order", - "device.ddp.color_order.gbr.desc": "GBR byte order", - "device.opc.url": "IP 地址:", - "device.opc.url.hint": "OPC 接收器地址。TCP 端口默认为 7890。", - "device.opc.url.placeholder": "192.168.1.50", - "device.opc_channel": "通道:", - "device.opc_channel.hint": "OPC 通道(0 = 广播到服务器所有通道,1-255 = 特定输出)。", - "device.serial_port": "串口:", - "device.serial_port.hint": "选择 Adalight 设备的 COM 端口", - "device.serial_port.none": "未找到串口", - "device.serial_port.select": "选择端口...", - "device.led_count_manual.hint": "灯带上的 LED 数量(必须与 Arduino 程序匹配)", - "device.baud_rate": "波特率:", - "device.baud_rate.hint": "串口通信速率。越高 FPS 越高,但需要与 Arduino 程序匹配。", - "device.led_type": "LED 类型:", - "device.led_type.hint": "RGB(3通道)或 RGBW(4通道,带独立白色)", - "device.send_latency": "发送延迟(毫秒):", - "device.send_latency.hint": "每帧模拟网络/串口延迟(毫秒)", - "device.css_processing_template": "色带处理模板:", - "device.css_processing_template.hint": "应用于此设备所有色带输出的默认处理模板", - "device.mqtt_topic": "MQTT 主题:", - "device.mqtt_topic.hint": "用于发布像素数据的 MQTT 主题路径(例如 mqtt://ledgrab/device/name)", - "device.mqtt_topic.placeholder": "mqtt://ledgrab/device/客厅", - "device.mqtt_source": "MQTT 代理:", - "device.mqtt_source.hint": "此设备发布到哪个 MQTT 代理。在集成 → MQTT 源中管理代理。留空则使用第一个可用代理。", - "device.mqtt_source.none": "— 第一个可用代理", - "device.ws_url": "连接 URL:", - "device.ws_url.hint": "客户端连接并接收 LED 数据的 WebSocket URL", - "device.openrgb.url": "OpenRGB URL:", - "device.openrgb.url.hint": "OpenRGB 服务器地址(例如 openrgb://localhost:6742/0)", - "device.openrgb.zone": "区域:", - "device.openrgb.zone.hint": "选择要控制的 LED 区域(全部不选则控制所有区域)", - "device.openrgb.zone.loading": "加载区域中…", - "device.openrgb.zone.error": "加载区域失败", - "device.openrgb.mode": "区域模式:", - "device.openrgb.mode.hint": "合并模式将所有区域作为一条连续 LED 灯带。独立模式让每个区域独立渲染完整效果。", - "device.openrgb.mode.combined": "合并灯带", - "device.openrgb.mode.separate": "独立区域", - "device.openrgb.added_multiple": "已添加 {count} 个设备", - "device.url.hint": "设备的 IP 地址或主机名(例如 http://192.168.1.100)", - "device.name": "设备名称:", - "device.name.placeholder": "客厅电视", - "device.url": "地址:", - "device.url.placeholder": "http://192.168.1.100", - "device.led_count": "LED 数量:", - "dashboard.device.chip": "芯片:", - "device.led_count.hint": "设备中配置的 LED 数量", - "device.led_count.hint.auto": "从设备自动检测", - "device.button.add": "添加设备", - "device.button.start": "启动", - "device.button.stop": "停止", - "device.button.settings": "常规设置", - "device.button.capture_settings": "采集设置", - "device.button.calibrate": "校准", - "device.button.remove": "移除", - "device.button.webui": "打开设备 Web UI", - "device.button.power_off": "关闭", - "device.button.ping": "Ping 设备", - "device.ping.online": "在线 ({ms}ms)", - "device.ping.offline": "设备离线", - "device.ping.error": "Ping 失败", - "device.power.off_success": "设备已关闭", - "device.status.connected": "已连接", - "device.status.disconnected": "已断开", - "device.status.error": "错误", - "device.status.processing": "处理中", - "device.status.idle": "空闲", - "device.fps": "FPS:", - "device.display": "显示器:", - "device.remove.confirm": "确定要移除此设备吗?", - "device.added": "设备添加成功", - "device.removed": "设备已移除", - "device.started": "处理已启动", - "device.stopped": "处理已停止", - "device.metrics.actual_fps": "实际 FPS", - "device.metrics.current_fps": "当前 FPS", - "device.metrics.target_fps": "目标 FPS", - "device.metrics.potential_fps": "潜在 FPS", - "device.metrics.frames": "帧数", - "device.metrics.frames_skipped": "已跳过", - "device.metrics.keepalive": "心跳", - "device.metrics.errors": "错误", - "device.metrics.uptime": "运行时长", - "device.metrics.timing": "管线时序:", - "device.metrics.device_fps": "设备刷新率", - "device.health.online": "在线", - "device.health.offline": "离线", - "device.health.streaming_unreachable": "流传输期间不可达", - "device.health.checking": "检测中...", - "patch.streaming": "传输中", - "patch.standby": "待机", - "patch.online": "在线", - "patch.offline": "离线", - "patch.disconnected": "已断开", - "patch.not_configured": "未配置", - "patch.unreachable": "无法访问", - "patch.checking": "检测中", - "patch.ticking": "运行中", - "patch.paused": "已暂停", - "patch.patched": "已连接", - "patch.preset": "预设", - "patch.source": "源", - "patch.template": "模板", - "patch.pipeline": "管线", - "patch.strip": "色带", - "patch.value": "值", - "patch.ready": "就绪", - "patch.polling": "轮询中", - "perf.idle": "空闲", - "perf.offline": "离线", - "perf.online": "在线", - "perf.no_devices": "无设备", - "perf.all_online": "全部在线", - "perf.online_count": "{count} 在线", - "perf.offline_count": "{count} 离线", - "perf.total_bytes": "共 {bytes}", - "perf.max_ms": "最大 {ms}毫秒", - "perf.targets_count.one": "{count} 个目标", - "perf.targets_count.other": "{count} 个目标", - "perf.no_captures": "无源", - "perf.captures_count.one": "{count} 个源", - "perf.captures_count.other": "{count} 个源", - "perf.ratio_of_requested": "请求的 {percent}% · {captures}", - "perf.total_count": "共 {count}", - "perf.skipped_per_sec": "跳过 {rate}/秒", - "perf.tip.now": "现在", - "perf.tip.ago": "−{seconds} 秒", - "device.last_seen.label": "最近检测", - "device.last_seen.just_now": "刚刚", - "device.last_seen.seconds": "%d秒前", - "device.last_seen.minutes": "%d分钟前", - "device.last_seen.hours": "%d小时前", - "device.last_seen.days": "%d天前", - "device.tutorial.start": "开始教程", - "device.tip.identity": "设备标识 — 名称、类型徽章和在线/离线状态指示。", - "device.tip.metadata": "设备地址和固件版本。点击 URL 可打开设备的内置 Web 界面。", - "device.tip.brightness": "拖动调节设备亮度。更改将立即发送到设备。", - "device.brightness": "亮度", - "device.tip.ping": "Ping 设备 — 刷新在线状态并测量延迟。", - "device.tip.settings": "打开设备设置 — 配置名称、地址、能力和健康检查。", - "device.tip.menu": "更多操作 — 复制、隐藏或删除此设备。", - "device.tip.add": "点击此处添加新的 LED 设备", - "settings.title": "设置", - "settings.tab.general": "常规", - "settings.tab.backup": "备份", - "settings.tab.mqtt": "MQTT", - "settings.tab.appearance": "外观", - "settings.logs.open_viewer": "打开日志查看器", - "settings.external_url.label": "外部 URL", - "settings.external_url.hint": "设置后,此基础 URL 将用于 webhook 链接和其他用户可见的链接,代替自动检测的本地 IP。示例:https://myserver.example.com:8080", - "settings.external_url.placeholder": "https://myserver.example.com:8080", - "settings.external_url.save": "保存", - "settings.external_url.saved": "外部 URL 已保存", - "settings.external_url.save_error": "保存外部 URL 失败", - "settings.general.title": "常规设置", - "settings.section.identity": "标识", - "settings.section.connection": "连接", - "settings.section.hardware": "硬件", - "settings.section.behavior": "行为", - "settings.section.provider": "提供商", - "settings.section.refresh": "刷新", - "settings.section.filters": "过滤器", - "settings.section.routing": "路由", - "settings.section.output": "输出", - "settings.section.filtering": "过滤", - "settings.section.broker": "代理服务器", - "settings.section.protocol": "协议", - "settings.section.adapter": "适配器", - "settings.section.mappings": "映射", - "settings.section.diagnostics": "诊断", - "settings.section.source": "源", - "settings.section.engine": "引擎", - "settings.section.timing": "时序", - "settings.section.gradient": "渐变", - "settings.section.layout": "布局", - "settings.section.strip": "灯带", - "settings.section.type": "类型", - "settings.section.notes": "备注", - "settings.section.file": "文件", - "settings.section.auth": "认证", - "settings.section.configure": "配置", - "settings.section.restart": "重启", - "settings.section.loopback": "本地访问", - "settings.section.test_setup": "测试设置", - "settings.section.offsets": "偏移", - "settings.section.line_properties": "线属性", - "settings.section.test": "测试", - "settings.section.preview": "预览", - "settings.section.controls": "控制", - "settings.section.history": "历史", - "ha_source.use_ssl.hint": "为 Home Assistant 启用 HTTPS / wss 连接", - "game_integration.enabled.hint": "已禁用的集成将停止轮询和发出事件", - "settings.capture.title": "采集设置", - "settings.capture.saved": "采集设置已更新", - "settings.capture.failed": "保存采集设置失败", - "settings.brightness": "亮度:", - "settings.brightness.hint": "此设备的全局亮度(0-100%)", - "settings.url.hint": "设备的 IP 地址或主机名", - "settings.display_index": "显示器:", - "settings.display_index.hint": "为此设备采集哪个屏幕", - "settings.fps": "目标 FPS:", - "settings.fps.hint": "目标帧率(10-90)", - "settings.capture_template": "引擎模板:", - "settings.capture_template.hint": "此设备的屏幕采集引擎和配置", - "settings.button.cancel": "取消", - "settings.health_interval": "健康检查间隔(秒):", - "settings.health_interval.hint": "检查设备状态的频率(5-600秒)", - "settings.auto_shutdown": "自动恢复:", - "settings.auto_shutdown.hint": "当目标停止或服务器关闭时恢复设备到空闲状态", - "settings.button.save": "保存更改", - "settings.saved": "设置保存成功", - "settings.failed": "保存设置失败", - "calibration.title": "LED 校准", - "calibration.tip.led_count": "输入每条边的 LED 数量", - "calibration.tip.start_corner": "点击角落设置起始位置", - "calibration.tip.direction": "切换灯带方向(顺时针/逆时针)", - "calibration.tip.offset": "设置 LED 偏移 — 从 LED 0 到起始角落的距离", - "calibration.tip.span": "拖动绿色条调整覆盖范围", - "calibration.tip.test": "点击边缘切换测试 LED", - "calibration.tip.overlay": "切换屏幕叠加层以查看显示器上的 LED 位置和编号", - "calibration.tip.toggle_inputs": "点击 LED 总数切换边缘输入", - "calibration.tip.border_width": "从屏幕边缘采样多少像素来确定 LED 颜色", - "calibration.tip.skip_leds_start": "跳过灯带起始端的 LED — 被跳过的 LED 保持关闭", - "calibration.tip.skip_leds_end": "跳过灯带末尾端的 LED — 被跳过的 LED 保持关闭", - "tour.welcome": "欢迎使用 LED Grab!快速导览将带您了解界面。使用方向键或按钮进行导航。", - "tour.dashboard": "仪表盘 — 实时查看运行中的目标、自动化和设备状态。", - "tour.targets": "目标 — 添加 WLED 设备,配置 LED 目标的捕获设置和校准。", - "tour.sources": "来源 — 管理捕获模板、图片来源、音频来源和色带。", - "tour.integrations": "集成 — 连接外部服务:天气、Home Assistant、MQTT 和游戏集成。", - "tour.graph": "图表 — 所有实体及其连接的可视化概览。拖动端口进行连接,右键单击边线断开连接。", - "tour.automations": "自动化 — 通过时间、音频或数值条件自动切换场景。", - "tour.settings": "设置 — 备份和恢复配置,管理自动备份。", - "tour.api": "API 文档 — 基于 Swagger 的交互式 REST API 文档。", - "tour.search": "搜索 — 使用 Ctrl+K 快速查找并导航到任意实体。", - "tour.theme": "主题 — 在深色和浅色模式之间切换。", - "tour.accent": "主题色 — 自定义界面的强调颜色。", - "tour.language": "语言 — 选择您偏好的界面语言。", - "tour.restart": "重新开始导览", - "tour.dash.perf": "性能 — 实时 FPS 图表、延迟指标和轮询间隔控制。", - "tour.dash.targets": "通道 — 您的所有目标集中显示,按运行中和已停止分组。", - "tour.dash.running": "运行中的目标 — 实时流媒体指标和快速停止控制。", - "tour.dash.stopped": "已停止的目标 — 一键启动。", - "tour.dash.automations": "自动化 — 活动自动化状态和快速启用/禁用切换。", - "tour.dash.scenes": "场景预设 — 已保存的系统快照,一键应用。", - "tour.dash.sync_clocks": "同步时钟 — 跨多个源同步动画的共享计时器。", - "tour.dash.integrations": "集成 — Weather、Home Assistant 和 MQTT 源的连接状态。", - "tour.dash.customize_btn": "自定义按钮 — 打开侧面板,可重新排序、隐藏或调整仪表盘布局。", - "tour.dash.customize_panel": "自定义仪表盘 — 布局控制中心。更改即时生效;按 Esc 或 × 关闭。", - "tour.dash.customize_presets": "预设 — 一键应用精选布局(紧凑、聚焦、完整)。切换预设会覆盖任何手动调整。", - "tour.dash.customize_global": "全局设置 — 内容宽度、动画级别、性能模式(system/app/both)和图表采样窗口。", - "tour.dash.customize_sections": "部分 — 拖拽以重新排序;「眼睛」可隐藏;「箭头」可设为默认折叠。每个部分还可选择密度(密集/紧凑/舒适)。", - "tour.dash.customize_perf_cells": "性能单元 — 选择性能部分中显示哪些指标,配置跨度、采样窗口和 Y 轴比例。", - "tour.tgt.nav": "使用此下拉菜单在目标类型之间切换:LED 设备、LED 目标和 Home Assistant 灯。", - "tour.tgt.devices": "设备 — 在网络中发现的 LED 控制器。", - "tour.tgt.css": "色带 — 定义屏幕区域如何映射到 LED 段。", - "tour.tgt.targets": "LED 目标 — 将设备、色带和捕获源组合进行流式传输。", - "tour.tgt.ha_light_targets": "HA 灯目标 — 选择 Home Assistant 灯实体和颜色/数值源进行驱动。", - "tour.src.nav": "使用此下拉菜单在源类型之间切换 — 图片、色带、音频、数值、同步和资源。", - "tour.src.raw": "原始 — 来自显示器的实时屏幕捕获源。", - "tour.src.templates": "捕获模板 — 可复用的捕获配置(分辨率、FPS、裁剪)。", - "tour.src.static": "静态图片 — 使用图片文件测试您的设置。", - "tour.src.processed": "处理 — 应用后处理效果,如模糊、亮度或色彩校正。", - "tour.src.color_strip": "色带 — 定义屏幕区域如何映射到 LED 段。", - "tour.src.audio": "音频 — 捕获麦克风/系统音频,并通过模板进行处理以生成反应式效果。", - "tour.src.value": "数值源 — 由时间、音频、系统指标、Home Assistant 实体、渐变或日程驱动的动态数字或颜色。用于动画效果、调制色带和触发自动化。", - "tour.src.sync": "同步时钟 — 在多个源之间同步动画的共享定时器。", - "tour.auto.list": "自动化 — 基于时间、音频或数值条件自动激活场景。", - "tour.auto.add": "点击 + 创建包含条件和要激活场景的新自动化。", - "tour.auto.card": "每张卡片显示自动化状态、条件和快速编辑/切换控制。", - "tour.auto.scenes_list": "场景 — 保存的系统状态,自动化可以激活或您可以手动应用。", - "tour.auto.scenes_add": "点击 + 将当前系统状态捕获为新的场景预设。", - "tour.auto.scenes_card": "每个场景卡片显示目标/设备数量。点击编辑、重新捕获或激活。", - "tour.int.nav": "使用此下拉菜单在集成类型之间切换:天气、Home Assistant、MQTT 和游戏集成。", - "tour.int.weather": "天气 — 获取实时天气数据(温度、状况、预报)作为环境效果的数值源。", - "tour.int.home_assistant": "Home Assistant — 连接到您的 HA 实例。灯、传感器和实体可作为数值源和目标端点。", - "tour.int.mqtt": "MQTT — 订阅代理主题,将外部传感器数据、自动化或远程触发器传入 LedGrab。", - "tour.int.game": "游戏 — 根据游戏内事件(Minecraft、Hammer of God 等)触发场景和色彩效果。", - "calibration.tutorial.start": "开始教程", - "calibration.overlay_toggle": "叠加层", - "calibration.start_position": "起始位置:", - "calibration.position.bottom_left": "左下", - "calibration.position.bottom_right": "右下", - "calibration.position.top_left": "左上", - "calibration.position.top_right": "右上", - "calibration.direction": "方向:", - "calibration.direction.clockwise": "顺时针", - "calibration.direction.counterclockwise": "逆时针", - "calibration.leds.top": "顶部 LED:", - "calibration.leds.right": "右侧 LED:", - "calibration.leds.bottom": "底部 LED:", - "calibration.leds.left": "左侧 LED:", - "calibration.offset": "LED 偏移:", - "calibration.offset.hint": "从物理 LED 0 到起始角落的距离(沿灯带方向)", - "calibration.skip_start": "跳过 LED(起始):", - "calibration.skip_start.hint": "灯带起始端关闭的 LED 数量(0 = 无)", - "calibration.skip_end": "跳过 LED(末尾):", - "calibration.skip_end.hint": "灯带末尾端关闭的 LED 数量(0 = 无)", - "calibration.border_width": "边框(像素):", - "calibration.roi": "采集区域(%):", - "calibration.roi.hint": "仅采集屏幕的此子区域,避免任务栏、游戏 HUD 或黑边干扰边缘颜色。全屏 = X/Y 为 0,宽/高为 100。", - "calibration.roi.x": "X (%)", - "calibration.roi.y": "Y (%)", - "calibration.roi.width": "宽度 (%)", - "calibration.roi.height": "高度 (%)", - "calibration.border_width.hint": "从屏幕边缘采样多少像素来确定 LED 颜色(1-100)", - "calibration.button.cancel": "取消", - "calibration.button.save": "保存", - "calibration.saved": "校准已保存", - "calibration.failed": "保存校准失败", - "server.healthy": "服务器在线", - "server.offline": "服务器离线", - "error.unauthorized": "未授权 - 请先登录", - "error.network": "网络错误", - "error.unknown": "发生错误", - "modal.discard_changes": "有未保存的更改。是否放弃?", - "confirm.title": "确认操作", - "confirm.yes": "是", - "confirm.no": "否", - "confirm.stop_all": "停止所有运行中的目标?", - "confirm.turn_off_device": "关闭此设备?", - "common.loading": "加载中...", - "common.delete": "删除", - "common.remove": "移除", - "common.edit": "编辑", - "common.clone": "克隆", - "common.hide": "隐藏卡片", - "common.more_actions": "更多操作", - "common.card_color": "卡片颜色", - "common.none": "无", - "common.none_no_cspt": "无(无处理模板)", - "common.none_no_input": "无(无输入源)", - "common.none_own_speed": "无(使用自身速度)", - "common.undo": "撤销", - "common.cancel": "取消", - "common.back": "返回", - "common.apply": "应用", - "common.start": "启动", - "common.stop": "停止", - "common.led_preview": "LED 预览", - "device.icon.eyebrow": "卡片图标", - "device.icon.title": "选择图标", - "device.icon.for": "用于", - "device.icon.choose": "选择图标…", - "device.icon.change": "更换图标…", - "device.icon.remove": "移除图标", - "device.icon.search.placeholder": "搜索图标…", - "device.icon.color_toggle": "颜色", - "device.icon.recent": "最近使用", - "device.icon.empty": "无匹配的图标。", - "device.icon.hint": "↵ 应用 · Esc 取消", - "device.icon.saved": "图标已保存", - "device.icon.error.save_failed": "保存图标失败", - "device.icon.cat.all": "全部", - "device.icon.cat.hardware": "硬件", - "device.icon.cat.lighting": "照明", - "device.icon.cat.rooms": "房间", - "device.icon.cat.media": "媒体", - "device.icon.cat.signal": "信号", - "device.icon.cat.ambience": "氛围", - "device.icon.cat.weather": "天气", - "device.icon.cat.nature": "自然", - "device.icon.cat.controls": "控件", - "device.icon.cat.status": "状态", - "device.icon.cat.office": "办公", - "device.icon.phone": "手机", - "device.icon.package": "设备模块", - "device.icon.code": "代码", - "device.icon.laptop": "笔记本电脑", - "device.icon.server": "服务器", - "device.icon.router": "路由器", - "device.icon.webcam": "网络摄像头", - "device.icon.bot": "机器人", - "device.icon.watch": "智能手表", - "device.icon.mcu": "微控制器", - "device.icon.dim": "柔光", - "device.icon.ring": "环形灯", - "device.icon.point": "点光源", - "device.icon.ceiling": "吸顶灯", - "device.icon.desk_lamp": "台灯", - "device.icon.wall_light": "壁灯", - "device.icon.flashlight": "手电筒", - "device.icon.bulb_off": "灯泡关闭", - "device.icon.candle": "蜡烛", - "device.icon.kitchen": "厨房", - "device.icon.bath": "浴室", - "device.icon.garage": "车库", - "device.icon.outdoor": "户外", - "device.icon.projector": "投影仪", - "device.icon.camcorder": "摄像机", - "device.icon.disc": "光盘", - "device.icon.image": "图片", - "device.icon.audio_file": "音频文件", - "device.icon.send": "发送", - "device.icon.rain": "雨", - "device.icon.snow": "雪", - "device.icon.thunder": "雷电", - "device.icon.fog": "雾", - "device.icon.wind": "风", - "device.icon.snowflake": "雪花", - "device.icon.umbrella": "雨伞", - "device.icon.sunrise": "日出", - "device.icon.sunset": "日落", - "device.icon.tree": "树", - "device.icon.flower": "花", - "device.icon.mountain": "山", - "device.icon.waves": "波浪", - "device.icon.sprout": "嫩芽", - "device.icon.water_drops": "水滴", - "device.icon.leaf": "叶子", - "device.icon.switch": "开关", - "device.icon.slider": "滑块", - "device.icon.settings": "设置", - "device.icon.refresh": "刷新", - "device.icon.undo": "撤销", - "device.icon.trash": "回收站", - "device.icon.link": "链接", - "device.icon.search": "搜索", - "device.icon.add": "添加", - "device.icon.show": "显示", - "device.icon.lock": "锁定", - "device.icon.key": "钥匙", - "device.icon.tool": "工具", - "device.icon.check": "勾选", - "device.icon.ok": "正常", - "device.icon.warning": "警告", - "device.icon.help": "帮助", - "device.icon.off": "已禁用", - "device.icon.shield": "护盾", - "device.icon.target": "目标", - "device.icon.pulse": "脉冲", - "device.icon.trend": "趋势", - "device.icon.battery": "电池", - "device.icon.calendar": "日历", - "device.icon.mail": "邮件", - "device.icon.coffee": "咖啡", - "device.icon.briefcase": "公文包", - "device.icon.doc": "文档", - "device.icon.checklist": "清单", - "device.icon.hashtag": "话题", - "device.icon.clock": "时钟", - "device.icon.entity.device": "设备", - "device.icon.entity.target": "LED 目标", - "device.icon.entity.ha_light_target": "HA 灯目标", - "device.icon.entity.picture_source": "画面源", - "device.icon.entity.audio_source": "音频源", - "device.icon.entity.weather_source": "天气源", - "device.icon.entity.value_source": "数值源", - "device.icon.entity.mqtt_source": "MQTT 源", - "device.icon.entity.ha_source": "Home Assistant 源", - "device.icon.entity.automation": "自动化", - "device.icon.entity.scene_preset": "场景预设", - "device.icon.entity.scene_playlist": "播放列表", - "device.icon.entity.sync_clock": "同步时钟", - "device.icon.entity.game_integration": "游戏集成", - "device.icon.entity.audio_processing_template": "音频处理模板", - "device.icon.entity.pattern_template": "图案模板", - "device.icon.entity.capture_template": "捕获模板", - "device.icon.entity.pp_template": "后处理模板", - "device.icon.entity.cspt": "色带处理模板", - "device.icon.entity.audio_template": "音频模板", - "device.icon.entity.gradient": "渐变", - "device.icon.entity.color_strip_source": "色带", - "device.icon.entity.asset": "资源", - "device.icon.inherited_from": "继承自 %s", - "device.icon.override_inherited": "覆盖继承的图标…", - "device.icon.use_inherited": "使用继承的", - "validation.required": "此字段为必填项", - "bulk.processing": "处理中…", - "api.error.timeout": "请求超时 — 请重试", - "api.error.network": "网络错误 — 请检查连接", - "palette.search": "搜索…", - "section.filter.placeholder": "筛选...", - "section.filter.reset": "清除筛选", - "tags.label": "标签", - "tags.hint": "为卡片分配标签以进行分组和筛选", - "tags.placeholder": "添加标签...", - "section.expand_all": "全部展开", - "section.collapse_all": "全部折叠", - "streams.title": "输入", - "integrations.title": "集成", - "streams.description": "源定义采集管线。原始源使用采集模板从显示器采集。处理源对另一个源应用后处理。将源分配给设备。", - "streams.group.raw": "源", - "streams.group.raw_templates": "引擎模板", - "streams.group.processed": "源", - "streams.group.proc_templates": "滤镜模板", - "streams.group.css_processing": "处理模板", - "streams.group.color_strip": "色带源", - "streams.group.audio": "音频", - "streams.group.audio_templates": "音频模板", - "streams.section.streams": "源", - "streams.add": "添加源", - "streams.add.raw": "添加屏幕采集", - "streams.add.processed": "添加处理源", - "streams.edit": "编辑源", - "streams.edit.raw": "编辑屏幕采集", - "streams.edit.processed": "编辑处理源", - "streams.name": "源名称:", - "streams.name.placeholder": "我的源", - "streams.type": "类型:", - "streams.type.raw": "屏幕采集", - "streams.type.processed": "已处理", - "streams.display": "显示器:", - "streams.display.hint": "采集哪个屏幕", - "streams.capture_template": "引擎模板:", - "streams.capture_template.hint": "定义屏幕采集方式的引擎模板", - "streams.target_fps": "目标 FPS:", - "streams.target_fps.hint": "采集的目标帧率(1-90)", - "streams.source": "源:", - "streams.source.hint": "要应用处理滤镜的源", - "streams.pp_template": "滤镜模板:", - "streams.pp_template.hint": "要应用到源的滤镜模板", - "streams.description_label": "描述(可选):", - "streams.description_placeholder": "描述此源...", - "streams.created": "源创建成功", - "streams.updated": "源更新成功", - "streams.deleted": "源删除成功", - "streams.delete.confirm": "确定要删除此源吗?", - "streams.modal.loading": "加载中...", - "streams.error.load": "加载源失败", - "streams.error.required": "请填写所有必填项", - "streams.error.delete": "删除源失败", - "streams.test.title": "测试源", - "streams.test.run": "运行", - "streams.test.running": "正在测试源...", - "streams.test.duration": "采集时长(秒):", - "streams.test.error.failed": "源测试失败", - "postprocessing.title": "滤镜模板", - "postprocessing.description": "处理模板定义图像滤镜和色彩校正。将它们分配给处理图片源以实现跨设备的一致后处理。", - "postprocessing.add": "添加滤镜模板", - "postprocessing.edit": "编辑滤镜模板", - "postprocessing.name": "模板名称:", - "postprocessing.name.placeholder": "我的滤镜模板", - "filters.select_type": "选择滤镜类型...", - "filters.add": "添加滤镜", - "filters.remove": "移除", - "filters.drag_to_reorder": "拖动以重新排序", - "filters.empty": "尚未添加滤镜。使用下方选择器添加滤镜。", - "filters.brightness": "亮度", - "filters.brightness.desc": "调整整体图像亮度", - "filters.saturation": "饱和度", - "filters.saturation.desc": "增强或降低色彩强度", - "filters.gamma": "伽马", - "filters.gamma.desc": "非线性亮度曲线校正", - "filters.downscaler": "缩小", - "filters.downscaler.desc": "降低分辨率以加快处理", - "filters.pixelate": "像素化", - "filters.pixelate.desc": "马赛克式块平均", - "filters.auto_crop": "自动裁剪", - "filters.auto_crop.desc": "移除信箱式内容的黑边", - "filters.flip": "翻转", - "filters.flip.desc": "水平或垂直镜像翻转", - "filters.color_correction": "色彩校正", - "filters.color_correction.desc": "白平衡和色温调整", - "filters.filter_template": "滤镜模板", - "filters.filter_template.desc": "嵌入另一个处理模板", - "filters.css_filter_template": "色带滤镜模板", - "filters.css_filter_template.desc": "嵌入另一个色带处理模板", - "filters.frame_interpolation": "帧插值", - "filters.frame_interpolation.desc": "帧间混合以获得更平滑的输出", - "filters.noise_gate": "噪声门", - "filters.noise_gate.desc": "抑制低于阈值的细微色彩变化", - "filters.palette_quantization": "调色板量化", - "filters.palette_quantization.desc": "将颜色减少到有限调色板", - "filters.reverse": "反转", - "filters.reverse.desc": "反转色带中的LED顺序", - "postprocessing.description_label": "描述(可选):", - "postprocessing.description_placeholder": "描述此模板...", - "postprocessing.created": "模板创建成功", - "postprocessing.updated": "模板更新成功", - "postprocessing.deleted": "模板删除成功", - "postprocessing.delete.confirm": "确定要删除此滤镜模板吗?", - "postprocessing.error.load": "加载处理模板失败", - "postprocessing.error.required": "请填写所有必填项", - "postprocessing.error.delete": "删除处理模板失败", - "postprocessing.config.show": "显示设置", - "postprocessing.test.title": "测试滤镜模板", - "postprocessing.test.source_stream": "源:", - "postprocessing.test.running": "正在测试处理模板...", - "postprocessing.test.error.no_stream": "请选择一个源", - "postprocessing.test.error.failed": "处理模板测试失败", - "css_processing.title": "色带处理模板", - "css_processing.add": "添加色带处理模板", - "css_processing.edit": "编辑色带处理模板", - "css_processing.name": "模板名称:", - "css_processing.name_placeholder": "我的色带处理模板", - "css_processing.description_label": "描述(可选):", - "css_processing.description_placeholder": "描述此模板...", - "css_processing.created": "色带处理模板已创建", - "css_processing.updated": "色带处理模板已更新", - "css_processing.deleted": "色带处理模板已删除", - "css_processing.delete.confirm": "确定要删除此色带处理模板吗?", - "css_processing.error.required": "请填写所有必填字段", - "css_processing.error.load": "加载色带处理模板出错", - "css_processing.error.delete": "删除色带处理模板出错", - "css_processing.error.clone_failed": "克隆色带处理模板失败", - "device.button.stream_selector": "源设置", - "device.stream_settings.title": "源设置", - "device.stream_selector.label": "源:", - "device.stream_selector.hint": "选择一个源来定义此设备采集和处理的内容", - "device.stream_selector.none": "-- 未分配源 --", - "device.stream_selector.saved": "源设置已更新", - "device.stream_settings.border_width": "边框宽度(像素):", - "device.stream_settings.border_width_hint": "从屏幕边缘采样多少像素来确定 LED 颜色(1-100)", - "device.stream_settings.interpolation": "插值模式:", - "device.stream_settings.interpolation.average": "平均", - "device.stream_settings.interpolation.median": "中位数", - "device.stream_settings.interpolation.dominant": "主色", - "device.stream_settings.interpolation_hint": "如何从采样像素计算 LED 颜色", - "device.stream_settings.smoothing": "平滑:", - "device.stream_settings.smoothing_hint": "帧间时间混合(0=无,1=完全)。减少闪烁。", - "device.tip.stream_selector": "为此设备配置图片源和 LED 投影设置", - "streams.group.static_image": "静态图片", - "streams.add.static_image": "添加静态图片源", - "streams.edit.static_image": "编辑静态图片源", - "streams.type.static_image": "静态图片", - "streams.group.video": "视频", - "streams.add.video": "添加视频源", - "streams.edit.video": "编辑视频源", - "picture_source.type.video": "视频", - "picture_source.type.video.desc": "从上传的视频素材中流式传输帧", - "picture_source.video.loop": "循环:", - "picture_source.video.speed": "播放速度:", - "picture_source.video.start_time": "开始时间(秒):", - "picture_source.video.end_time": "结束时间(秒):", - "picture_source.video.resolution_limit": "最大宽度(像素):", - "picture_source.video.resolution_limit.hint": "解码时缩小视频以提高性能", - "streams.image_asset": "图片素材:", - "streams.image_asset.select": "选择图片素材…", - "streams.image_asset.search": "搜索图片素材…", - "streams.video_asset": "视频素材:", - "streams.video_asset.select": "选择视频素材…", - "streams.video_asset.search": "搜索视频素材…", - "targets.title": "通道", - "targets.description": "目标将色带源桥接到输出设备。每个目标引用一个设备和一个色带源。", - "targets.subtab.wled": "LED", - "targets.subtab.led": "LED", - "targets.section.devices": "设备", - "targets.section.color_strips": "色带源", - "targets.section.targets": "目标", - "targets.section.specific_settings": "特定设置", - "targets.section.advanced": "高级", - "targets.add": "添加目标", - "targets.edit": "编辑目标", - "targets.loading": "正在加载目标...", - "targets.none": "尚未配置目标", - "targets.failed": "加载目标失败", - "targets.name": "目标名称:", - "targets.name.placeholder": "我的目标", - "targets.device": "设备:", - "targets.device.hint": "选择要发送数据的 LED 设备", - "targets.device.none": "-- 选择设备 --", - "targets.color_strip_source": "色带源:", - "targets.color_strip_source.hint": "选择为此目标提供 LED 颜色的色带源", - "targets.no_css": "无源", - "targets.source": "源:", - "targets.source.hint": "要采集和处理的图片源", - "targets.source.none": "-- 未分配源 --", - "targets.fps": "目标 FPS:", - "targets.fps.hint": "采集和 LED 更新的目标帧率(1-90)", - "targets.fps.rec": "硬件最大 ≈ {fps} fps({leds} 个 LED)", - "targets.border_width": "边框宽度(像素):", - "targets.border_width.hint": "从屏幕边缘采样多少像素来确定 LED 颜色(1-100)", - "targets.interpolation": "插值模式:", - "targets.interpolation.hint": "如何从采样像素计算 LED 颜色", - "targets.interpolation.average": "平均", - "targets.interpolation.median": "中位数", - "targets.interpolation.dominant": "主色", - "targets.smoothing": "平滑:", - "targets.smoothing.hint": "帧间时间混合(0=无,1=完全)。减少闪烁。", - "targets.keepalive_interval": "心跳间隔:", - "targets.keepalive_interval.hint": "源静态时重新发送最后一帧的频率,保持设备在活动模式(0.5-5.0秒)", - "targets.created": "目标创建成功", - "targets.updated": "目标更新成功", - "targets.deleted": "目标删除成功", - "targets.delete.confirm": "确定要删除此目标吗?", - "targets.error.load": "加载目标失败", - "targets.error.required": "请填写所有必填项", - "targets.error.name_required": "请输入目标名称", - "targets.error.delete": "删除目标失败", - "targets.button.start": "启动", - "targets.button.stop": "停止", - "targets.status.processing": "处理中", - "targets.status.idle": "空闲", - "targets.status.error": "错误", - "targets.metrics.actual_fps": "实际 FPS", - "targets.metrics.target_fps": "目标 FPS", - "targets.metrics.frames": "帧数", - "targets.metrics.errors": "错误", - "targets.section.pattern_templates": "图案模板", - "pattern.add": "添加图案模板", - "pattern.edit": "编辑图案模板", - "pattern.name": "模板名称:", - "pattern.name.placeholder": "我的图案模板", - "pattern.description_label": "描述(可选):", - "pattern.description_placeholder": "描述此图案...", - "pattern.rectangles": "矩形", - "pattern.rect.name": "名称", - "pattern.rect.x": "X", - "pattern.rect.y": "Y", - "pattern.rect.width": "宽", - "pattern.rect.height": "高", - "pattern.rect.add": "添加矩形", - "pattern.rect.remove": "移除", - "pattern.rect.empty": "尚未定义矩形。请至少添加一个矩形。", - "pattern.created": "图案模板创建成功", - "pattern.updated": "图案模板更新成功", - "pattern.deleted": "图案模板删除成功", - "pattern.delete.confirm": "确定要删除此图案模板吗?", - "pattern.delete.referenced": "无法删除:此模板正被目标引用", - "pattern.error.required": "请填写所有必填项", - "pattern.visual_editor": "可视编辑器", - "pattern.capture_bg": "采集背景", - "pattern.source_for_bg": "背景源:", - "pattern.source_for_bg.none": "-- 选择源 --", - "pattern.delete_selected": "删除选中", - "pattern.name.hint": "此矩形布局的描述性名称", - "pattern.description.hint": "关于此图案使用位置或方式的可选说明", - "pattern.visual_editor.hint": "点击 + 按钮添加矩形。拖动边缘调整大小,拖动内部移动位置。", - "pattern.rectangles.hint": "用精确坐标(0.0 到 1.0)微调矩形位置和大小", - "overlay.toggle": "切换屏幕叠加层", - "overlay.button.show": "显示叠加层可视化", - "overlay.button.hide": "隐藏叠加层可视化", - "overlay.started": "叠加层可视化已启动", - "overlay.stopped": "叠加层可视化已停止", - "overlay.error.start": "启动叠加层失败", - "overlay.error.stop": "停止叠加层失败", - "sidebar.workspaces": "工作区", - "sidebar.load": "负载", - "sidebar.fps": "帧率", - "transport.status.ready": "就绪", - "transport.status.armed": "运行中 · {n}", - "transport.meta.uptime": "在线", - "transport.meta.cpu": "CPU", - "transport.meta.mem": "内存", - "transport.meta.poll": "轮询", - "transport.meta.poll_hint": "轮询间隔(点击:1秒 → 2秒 → 5秒 → 10秒)", - "dashboard.title": "仪表盘", - "dashboard.section.targets": "通道", - "dashboard.section.running": "运行中", - "dashboard.section.stopped": "已停止", - "dashboard.no_targets": "尚未配置目标", - "dashboard.uptime": "运行时长", - "dashboard.fps": "FPS", - "dashboard.errors": "错误", - "dashboard.device": "设备", - "dashboard.stop_all": "全部停止", - "dashboard.failed": "加载仪表盘失败", - "dashboard.section.automations": "自动化", - "dashboard.section.scenes": "场景预设", - "dashboard.section.playlists": "播放列表", - "dashboard.section.sync_clocks": "同步时钟", - "dashboard.targets": "目标", - "dashboard.section.performance": "系统性能", - "dashboard.perf.active_patches": "活动通道", - "dashboard.perf.patches.empty.idle": "准备就绪", - "dashboard.perf.patches.empty.none": "暂无通道", - "dashboard.perf.total_fps": "总帧率", - "dashboard.perf.total_capture_fps": "总输入帧率", - "dashboard.perf.total_capture_fps_actual": "总采集帧率", - "dashboard.perf.network": "网络", - "dashboard.perf.device_latency": "设备延迟", - "dashboard.perf.send_timing": "发送耗时", - "dashboard.perf.errors": "错误", - "dashboard.perf.devices": "设备", - "dashboard.perf.cpu": "CPU", - "dashboard.perf.ram": "内存", - "dashboard.perf.gpu": "GPU", - "dashboard.perf.temp": "温度", - "dashboard.perf.temp.install_lhm": "Windows 没有内置的 CPU 温度 API。请安装 LibreHardwareMonitor 并启用“Publish to WMI”以在此处查看实时读数。", - "dashboard.perf.unavailable": "不可用", - "dashboard.perf.color": "图表颜色", - "dashboard.perf.mode.system": "系统", - "dashboard.perf.mode.app": "应用", - "dashboard.perf.mode.both": "全部", - "dashboard.poll_interval": "刷新间隔", - "dashboard.customize.title": "自定义仪表盘", - "dashboard.customize.presets": "预设", - "dashboard.customize.preset.studio": "工作室", - "dashboard.customize.preset.operator": "操作员", - "dashboard.customize.preset.showrunner": "演出", - "dashboard.customize.preset.diagnostics": "诊断", - "dashboard.customize.preset.tv": "电视", - "dashboard.customize.modified": "已修改", - "dashboard.customize.global": "全局", - "dashboard.customize.width": "宽度", - "dashboard.customize.width.full": "全宽", - "dashboard.customize.width.centered": "居中", - "dashboard.customize.width.narrow": "窄", - "dashboard.customize.anim": "动画", - "dashboard.customize.anim.full": "完整", - "dashboard.customize.anim.reduced": "减少", - "dashboard.customize.anim.off": "关闭", - "dashboard.customize.perf_mode": "性能模式", - "dashboard.customize.sections": "分区", - "dashboard.customize.perf_cells": "性能面板", - "dashboard.customize.fixed_top": "固定在顶部", - "dashboard.customize.drag_help": "拖动行重新排序,或使用 ↑/↓ 按钮。", - "dashboard.customize.cell_drag_help": "拖动行可更改仪表盘性能条中单元格的顺序。", - "dashboard.customize.window": "采样窗口", - "dashboard.customize.scale": "Y 轴刻度", - "dashboard.customize.mode_short": "模式", - "dashboard.customize.window_short": "窗口", - "dashboard.customize.scale_short": "刻度", - "dashboard.customize.density.comfortable": "宽松", - "dashboard.customize.density.compact": "紧凑", - "dashboard.customize.density.dense": "密集", - "card_mode.tooltip": "卡片大小", - "card_mode.comfortable": "宽松", - "card_mode.compact": "紧凑", - "card_mode.dense": "密集", - "card_mode.row": "列表", - "dashboard.customize.collapse_default.on": "默认折叠", - "dashboard.customize.collapse_default.off": "默认展开", - "dashboard.customize.show": "显示", - "dashboard.customize.hide": "隐藏", - "dashboard.customize.mode.inherit": "继承", - "dashboard.customize.mode.system": "系统", - "dashboard.customize.mode.app": "应用", - "dashboard.customize.mode.both": "两者", - "dashboard.customize.yscale.auto": "自动", - "dashboard.customize.yscale.fixed": "固定", - "dashboard.customize.yscale.log": "对数", - "dashboard.customize.export": "导出", - "dashboard.customize.import": "导入", - "dashboard.customize.reset": "重置", - "dashboard.customize.reset_confirm": "将仪表盘布局重置为「工作室」预设?", - "dashboard.customize.exported": "布局已导出", - "dashboard.customize.imported": "布局已导入", - "dashboard.customize.import_failed": "导入布局失败", - "automations.title": "自动化", - "automations.empty": "尚未配置自动化。创建一个以自动激活场景。", - "automations.add": "添加自动化", - "automations.edit": "编辑自动化", - "automations.delete.confirm": "删除自动化 \"{name}\"?", - "automations.section.triggers": "触发器", - "automations.section.action": "动作", - "automations.section.deactivation": "停用", - "automations.name": "名称:", - "automations.name.hint": "此自动化的描述性名称", - "automations.name.placeholder": "我的自动化", - "automations.enabled": "启用:", - "automations.enabled.hint": "禁用的自动化即使满足条件也不会激活", - "automations.rule_logic": "条件逻辑:", - "automations.rule_logic.hint": "多个条件的组合方式:任一(或)或 全部(与)", - "automations.rule_logic.or": "任一条件(或)", - "automations.rule_logic.and": "全部条件(与)", - "automations.rule_logic.or.desc": "任一条件匹配时触发", - "automations.rule_logic.and.desc": "全部匹配时才触发", - "automations.rules": "条件:", - "automations.rules.hint": "决定此自动化何时激活的规则", - "automations.rules.add": "添加条件", - "automations.rules.empty": "无条件 — 启用后自动化始终处于活动状态", - "automations.rule.startup": "启动", - "automations.rule.startup.desc": "服务器启动时", - "automations.rule.startup.hint": "服务器启动时激活,启用期间保持活动。", - "automations.rule.application": "应用程序", - "automations.rule.application.desc": "应用运行/聚焦", - "automations.rule.application.apps": "应用程序:", - "automations.rule.application.apps.hint": "进程名,每行一个(例如 firefox.exe)", - "automations.rule.application.browse": "浏览", - "automations.rule.application.search": "筛选进程...", - "automations.rule.application.no_processes": "未找到进程", - "automations.rule.application.match_type": "匹配类型:", - "automations.rule.application.match_type.hint": "如何检测应用程序", - "automations.rule.application.match_type.running": "运行中", - "automations.rule.application.match_type.running.desc": "进程活跃", - "automations.rule.application.match_type.topmost": "最前", - "automations.rule.application.match_type.topmost.desc": "前台窗口", - "automations.rule.application.match_type.topmost_fullscreen": "最前 + 全屏", - "automations.rule.application.match_type.topmost_fullscreen.desc": "前台 + 全屏", - "automations.rule.application.match_type.fullscreen": "全屏", - "automations.rule.application.match_type.fullscreen.desc": "任意全屏应用", - "automations.rule.application.apps.hint_android": "包名,每行一个(例如 com.netflix.mediaclient)", - "automations.rule.application.search_apps": "筛选应用…", - "automations.rule.application.no_apps": "未找到应用", - "automations.rule.application.usage_access_required": "需要使用情况访问权限。在您的 LedGrab 电视上打开应用并点按「授予使用情况访问权限」。", - "automations.rule.time_of_day": "时段", - "automations.rule.time_of_day.desc": "时间范围", - "automations.rule.time_of_day.start_time": "开始时间:", - "automations.rule.time_of_day.end_time": "结束时间:", - "automations.rule.time_of_day.overnight_hint": "跨夜时段(如 22:00–06:00),请将开始时间设为晚于结束时间。", - "automations.rule.time_of_day.days": "生效日期", - "automations.rule.time_of_day.days_hint": "全部不选表示每天生效。跨夜时段归属于其开始的那一天。", - "automations.rule.time_of_day.timezone": "时区", - "automations.rule.time_of_day.timezone.placeholder": "服务器本地时间(如 Europe/Berlin)", - "weekday.short.0": "周一", - "weekday.short.1": "周二", - "weekday.short.2": "周三", - "weekday.short.3": "周四", - "weekday.short.4": "周五", - "weekday.short.5": "周六", - "weekday.short.6": "周日", - "automations.rule.system_idle": "系统空闲", - "automations.rule.system_idle.desc": "空闲/活跃", - "automations.rule.system_idle.idle_minutes": "空闲超时(分钟):", - "automations.rule.system_idle.mode": "触发模式:", - "automations.rule.system_idle.when_idle": "空闲时", - "automations.rule.system_idle.when_idle.desc": "当用户空闲时间超过超时阈值后触发", - "automations.rule.system_idle.when_active": "活跃时", - "automations.rule.system_idle.when_active.desc": "当用户正在使用系统时触发", - "automations.rule.display_state": "显示器状态", - "automations.rule.display_state.desc": "显示器开/关", - "automations.rule.display_state.state": "显示器状态:", - "automations.rule.display_state.on": "开启", - "automations.rule.display_state.off": "关闭(休眠)", - "automations.rule.mqtt": "MQTT", - "automations.rule.mqtt.desc": "MQTT 消息", - "automations.rule.mqtt.topic": "主题:", - "automations.rule.mqtt.payload": "消息内容:", - "automations.rule.mqtt.match_mode": "匹配模式:", - "automations.rule.mqtt.match_mode.exact": "精确匹配", - "automations.rule.mqtt.match_mode.contains": "包含", - "automations.rule.mqtt.match_mode.regex": "正则表达式", - "automations.rule.mqtt.hint": "当 MQTT 主题收到匹配的消息时激活", - "automations.rule.webhook": "Webhook", - "automations.rule.webhook.desc": "HTTP 回调", - "automations.rule.webhook.hint": "通过外部服务的 HTTP 请求激活(Home Assistant、IFTTT、curl 等)", - "automations.rule.webhook.url": "Webhook URL:", - "automations.rule.webhook.copy": "复制", - "automations.rule.webhook.copied": "已复制!", - "automations.rule.webhook.save_first": "请先保存自动化以生成 Webhook URL", - "automations.scene": "场景:", - "automations.scene.hint": "条件满足时激活的场景预设", - "automations.scene.search_placeholder": "搜索场景...", - "automations.scene.none_selected": "无(无场景)", - "automations.scene.none_available": "没有可用的场景", - "automations.deactivation_mode": "停用方式:", - "automations.deactivation_mode.hint": "条件不再满足时的行为", - "automations.deactivation_mode.none": "无", - "automations.deactivation_mode.none.desc": "保持当前状态", - "automations.deactivation_mode.revert": "恢复", - "automations.deactivation_mode.revert.desc": "恢复到之前的状态", - "automations.deactivation_mode.fallback_scene": "备用", - "automations.deactivation_mode.fallback_scene.desc": "激活备用场景", - "automations.deactivation_scene": "备用场景:", - "automations.deactivation_scene.hint": "自动化停用时激活的场景", - "automations.status.active": "活动", - "automations.status.inactive": "非活动", - "automations.status.disabled": "已禁用", - "automations.action.disable": "禁用", - "automations.last_activated": "上次激活", - "automations.logic.and": " 与 ", - "automations.logic.or": " 或 ", - "automations.logic.all": "全部", - "automations.logic.any": "任一", - "automations.updated": "自动化已更新", - "automations.created": "自动化已创建", - "automations.deleted": "自动化已删除", - "automations.error.name_required": "名称为必填项", - "automations.error.clone_failed": "克隆自动化失败", - "automations.error.load_failed": "加载自动化失败", - "automations.error.save_failed": "保存自动化失败", - "automations.error.delete_failed": "删除自动化失败", - "automations.error.toggle_failed": "切换自动化失败", - "scenes.title": "场景", - "scenes.add": "捕获场景", - "scenes.edit": "编辑场景", - "scenes.name": "名称:", - "scenes.name.hint": "此场景预设的描述性名称", - "scenes.name.placeholder": "我的场景", - "scenes.description": "描述:", - "scenes.description.hint": "此场景功能的可选描述", - "scenes.targets": "目标:", - "scenes.targets.hint": "选择要包含在此场景快照中的目标", - "scenes.targets.add": "添加目标", - "scenes.targets.search_placeholder": "搜索目标...", - "scenes.targets.empty": "未选择目标", - "scenes.capture": "捕获", - "scenes.activate": "激活场景", - "scenes.recapture": "重新捕获当前状态", - "scenes.action.activate": "激活", - "scenes.action.recapture": "重新捕获", - "scenes.status.preset": "预设", - "scenes.delete": "删除场景", - "scenes.targets_count": "目标", - "scenes.captured": "场景已捕获", - "scenes.updated": "场景已更新", - "scenes.activated": "场景已激活", - "scenes.activated_partial": "场景部分激活", - "scenes.errors": "错误", - "scenes.recaptured": "场景已重新捕获", - "scenes.deleted": "场景已删除", - "scenes.recapture_confirm": "将当前状态重新捕获到\"{name}\"中?", - "scenes.delete_confirm": "删除场景\"{name}\"?", - "scenes.error.name_required": "名称为必填项", - "scenes.error.save_failed": "保存场景失败", - "scenes.error.activate_failed": "激活场景失败", - "scenes.error.recapture_failed": "重新捕获场景失败", - "scenes.error.delete_failed": "删除场景失败", - "scenes.cloned": "场景已克隆", - "scenes.error.clone_failed": "克隆场景失败", - "playlists.title": "播放列表", - "playlists.add": "新建播放列表", - "playlists.edit": "编辑播放列表", - "playlists.name": "名称:", - "playlists.name.placeholder": "我的播放列表", - "playlists.description": "描述:", - "playlists.description.hint": "此播放列表的可选描述", - "playlists.section.playback": "播放", - "playlists.loop": "循环:", - "playlists.loop.hint": "最后一个场景结束后从第一个重新开始;关闭则播放一遍后停止", - "playlists.shuffle": "随机:", - "playlists.shuffle.hint": "每个循环开始时随机打乱场景顺序", - "playlists.scenes": "场景:", - "playlists.scenes.hint": "此播放列表循环的场景预设,每个按各自的时长保持", - "playlists.scenes.add": "添加场景", - "playlists.scenes_count": "个场景", - "playlists.scene_one": "个场景", - "playlists.scene_many": "个场景", - "playlists.items.empty": "还没有场景 — 在下方添加", - "playlists.items.search_placeholder": "搜索场景...", - "playlists.item.duration": "秒", - "playlists.item.move_up": "上移", - "playlists.item.move_down": "下移", - "playlists.item.missing": "缺失", - "playlists.chip.loop": "循环", - "playlists.chip.shuffle": "随机", - "playlists.action.start": "开始", - "playlists.action.stop": "停止", - "playlists.start": "开始播放列表", - "playlists.stop": "停止播放列表", - "playlists.status.playing": "播放中", - "playlists.status.stopped": "已停止", - "playlists.started": "播放列表已开始", - "playlists.stopped": "播放列表已停止", - "playlists.created": "播放列表已创建", - "playlists.updated": "播放列表已更新", - "playlists.deleted": "播放列表已删除", - "playlists.delete_confirm": "删除播放列表“{name}”?", - "playlists.error.name_required": "需要名称", - "playlists.error.save_failed": "保存播放列表失败", - "playlists.error.start_failed": "启动播放列表失败", - "playlists.error.stop_failed": "停止播放列表失败", - "playlists.error.delete_failed": "删除播放列表失败", - "playlists.error.no_presets": "请先创建场景预设", - "dashboard.type.led": "LED", - "dashboard.type.kc": "关键颜色", - "aria.close": "关闭", - "aria.save": "保存", - "aria.cancel": "取消", - "aria.previous": "上一个", - "aria.next": "下一个", - "aria.hint": "显示提示", - "color_strip.select_type": "选择色带类型", - "color_strip.add": "添加", - "color_strip.edit": "编辑", - "color_strip.name": "名称:", - "color_strip.name.placeholder": "墙壁灯带", - "color_strip.picture_source": "图片源:", - "color_strip.picture_source.hint": "用作 LED 颜色计算输入的屏幕采集源", - "color_strip.fps": "目标 FPS:", - "color_strip.fps.hint": "LED 颜色更新的目标帧率(10-90)", - "color_strip.interpolation": "颜色模式:", - "color_strip.interpolation.hint": "如何从采样的边框像素计算 LED 颜色", - "color_strip.interpolation.average": "平均", - "color_strip.interpolation.median": "中位数", - "color_strip.interpolation.dominant": "主色", - "color_strip.interpolation.average.desc": "将所有采样像素混合为平滑颜色", - "color_strip.interpolation.median.desc": "取中间颜色值,减少异常值", - "color_strip.interpolation.dominant.desc": "使用样本中出现最频繁的颜色", - "color_strip.smoothing": "平滑:", - "color_strip.smoothing.hint": "帧间时间混合(0=无,1=完全)。减少闪烁。", - "color_strip.frame_interpolation": "帧插值:", - "color_strip.frame_interpolation.hint": "在连续采集帧之间混合,以在采集速率较低时仍以完整目标 FPS 输出。减少慢速环境过渡时的可见阶梯效应。", - "color_strip.color_corrections": "色彩校正", - "color_strip.brightness": "亮度:", - "color_strip.brightness.hint": "输出亮度倍数(0=关闭,1=不变,2=加倍)。在颜色提取后应用。", - "color_strip.saturation": "饱和度:", - "color_strip.saturation.hint": "颜色饱和度(0=灰度,1=不变,2=双倍饱和度)", - "color_strip.gamma": "伽马:", - "color_strip.gamma.hint": "伽马校正(1=无,<1=更亮的中间调,>1=更暗的中间调)", - "color_strip.test_device": "测试设备:", - "color_strip.test_device.hint": "选择一个设备,在点击边缘切换时发送测试像素", - "color_strip.leds": "LED 数量", - "color_strip.led_count": "LED 数量:", - "color_strip.led_count.hint": "物理灯带上的 LED 总数。屏幕源:0 = 从校准自动获取(未映射到边缘的额外 LED 将为黑色)。静态颜色:设置为与设备 LED 数量匹配。", - "color_strip.created": "色带源已创建", - "color_strip.updated": "色带源已更新", - "color_strip.deleted": "色带源已删除", - "color_strip.delete.confirm": "确定要删除此色带源吗?", - "color_strip.delete.referenced": "无法删除:此源正在被目标使用", - "color_strip.error.name_required": "请输入名称", - "color_strip.type": "类型:", - "color_strip.type.hint": "图片源从屏幕采集推导 LED 颜色。单色用单一颜色填充所有 LED。渐变在所有 LED 上分布颜色渐变。颜色循环平滑循环用户定义的颜色列表。组合将多个源作为混合图层叠加。音频响应从实时音频输入驱动 LED。API 输入通过 REST 或 WebSocket 从外部客户端接收原始 LED 颜色。", - "color_strip.type.picture": "图片源", - "color_strip.type.picture.desc": "从屏幕捕获获取颜色", - "color_strip.type.picture_advanced": "多显示器", - "color_strip.type.picture_advanced.desc": "跨显示器的线条校准", - "color_strip.type.single_color": "单色", - "color_strip.type.single_color.desc": "单色填充", - "color_strip.type.gradient": "渐变", - "color_strip.type.gradient.desc": "LED上的平滑颜色过渡", - "color_strip.single_color": "颜色:", - "color_strip.single_color.hint": "将发送到灯带上所有 LED 的纯色。", - "color_strip.gradient.preview": "渐变:", - "color_strip.gradient.preview.hint": "可视预览。点击下方标记轨道添加色标。拖动标记重新定位。", - "color_strip.gradient.stops": "色标:", - "color_strip.gradient.stops.hint": "每个色标在相对位置定义一种颜色(0.0 = 起始,1.0 = 结束)。↔ 按钮添加右侧颜色以在该色标处创建硬边。", - "color_strip.gradient.stops_count": "个色标", - "color_strip.gradient.add_stop": "+ 添加色标", - "color_strip.gradient.position": "位置(0.0-1.0)", - "color_strip.gradient.bidir.hint": "在此色标右侧添加第二种颜色以在渐变中创建硬边。", - "color_strip.gradient.min_stops": "渐变至少需要 2 个色标", - "color_strip.gradient.preset": "预设:", - "color_strip.gradient.preset.hint": "加载预定义的渐变调色板。选择预设将替换当前色标。", - "color_strip.gradient.preset.custom": "— 自定义 —", - "color_strip.gradient.preset.rainbow": "彩虹", - "color_strip.gradient.preset.sunset": "日落", - "color_strip.gradient.preset.ocean": "海洋", - "color_strip.gradient.preset.forest": "森林", - "color_strip.gradient.preset.fire": "火焰", - "color_strip.gradient.preset.lava": "熔岩", - "color_strip.gradient.preset.aurora": "极光", - "color_strip.gradient.preset.ice": "冰", - "color_strip.gradient.preset.warm": "暖色", - "color_strip.gradient.preset.cool": "冷色", - "color_strip.gradient.preset.neon": "霓虹", - "color_strip.gradient.preset.pastel": "柔和", - "color_strip.gradient.preset.save_button": "保存为预设…", - "color_strip.gradient.preset.save_prompt": "输入预设名称:", - "color_strip.gradient.preset.saved": "预设已保存", - "color_strip.gradient.preset.deleted": "预设已删除", - "color_strip.gradient.preset.apply": "应用", - "color_strip.animation": "动画", - "color_strip.animation.type": "效果:", - "color_strip.animation.type.hint": "要应用的动画效果。", - "color_strip.animation.type.none": "无(无动画效果)", - "color_strip.animation.type.none.desc": "静态颜色,无动画", - "color_strip.animation.type.breathing": "呼吸", - "color_strip.animation.type.breathing.desc": "平滑的亮度渐入渐出", - "color_strip.animation.type.gradient_shift": "渐变移动", - "color_strip.animation.type.gradient_shift.desc": "渐变沿灯带滑动", - "color_strip.animation.type.wave": "波浪", - "color_strip.animation.type.wave.desc": "沿灯带移动的正弦亮度波", - "color_strip.animation.type.strobe": "频闪", - "color_strip.animation.type.strobe.desc": "快速开/关闪烁", - "color_strip.animation.type.sparkle": "闪烁", - "color_strip.animation.type.sparkle.desc": "随机 LED 短暂闪亮", - "color_strip.animation.type.pulse": "脉冲", - "color_strip.animation.type.pulse.desc": "快速衰减的尖锐亮度脉冲", - "color_strip.animation.type.candle": "烛光", - "color_strip.animation.type.candle.desc": "温暖的类似蜡烛的闪烁光芒", - "color_strip.animation.type.rainbow_fade": "彩虹渐变", - "color_strip.animation.type.rainbow_fade.desc": "循环整个色相光谱", - "color_strip.animation.speed": "速度:", - "color_strip.animation.speed.hint": "动画速度倍数。1.0 ≈ 呼吸效果每秒一个循环;更高值循环更快。", - "color_strip.type.effect": "效果", - "color_strip.type.effect.desc": "程序化效果:火焰、等离子、极光", - "color_strip.type.effect.hint": "实时生成的程序化 LED 效果(火焰、流星、等离子、噪声、极光)。", - "color_strip.type.composite": "组合", - "color_strip.type.composite.desc": "叠加和混合多个源", - "color_strip.type.composite.hint": "将多个色带源作为图层叠加,支持混合模式和不透明度。", - "color_strip.type.mapped": "映射", - "color_strip.type.mapped.desc": "为LED区域分配源", - "color_strip.type.mapped.hint": "将不同色带源分配到不同 LED 范围(区域)。与组合的图层混合不同,映射将源并排放置。", - "color_strip.type.audio": "音频响应", - "color_strip.type.audio.desc": "由音频输入驱动LED", - "color_strip.type.audio.hint": "LED 颜色由实时音频输入驱动 — 系统音频或麦克风。", - "color_strip.type.api_input": "API 输入", - "color_strip.type.api_input.desc": "从外部应用接收颜色", - "color_strip.type.api_input.hint": "通过 REST POST 或 WebSocket 从外部客户端接收原始 LED 颜色数组。用于与自定义软件、家庭自动化或任何能发送 HTTP 请求的系统集成。", - "color_strip.api_input.fallback_color": "备用颜色:", - "color_strip.api_input.fallback_color.hint": "超时未收到数据时显示的颜色。启动时和连接丢失后 LED 将显示此颜色。", - "color_strip.api_input.timeout": "超时(秒):", - "color_strip.api_input.timeout.hint": "等待新颜色数据多长时间后恢复为备用颜色。设为 0 表示永不超时。", - "color_strip.api_input.endpoints": "推送端点:", - "color_strip.api_input.endpoints.hint": "使用这些 URL 从外部应用程序推送 LED 颜色数据。REST 接受 JSON,WebSocket 接受 JSON 和原始二进制帧。", - "color_strip.api_input.save_first": "请先保存源以查看推送端点 URL。", - "color_strip.api_input.interpolation": "LED 插值:", - "color_strip.api_input.interpolation.hint": "当传入的 LED 数量与设备 LED 数量不同时如何调整大小。线性提供平滑混合,最近邻保持锐利边缘,无则截断或补零。", - "color_strip.api_input.interpolation.linear": "线性", - "color_strip.api_input.interpolation.linear.desc": "LED 之间平滑混合", - "color_strip.api_input.interpolation.nearest": "最近邻", - "color_strip.api_input.interpolation.nearest.desc": "锐利边缘,无混合", - "color_strip.api_input.interpolation.none": "无", - "color_strip.api_input.interpolation.none.desc": "截断或补零", - "color_strip.type.notification": "通知", - "color_strip.type.notification.desc": "通过Webhook触发的一次性效果", - "color_strip.type.notification.hint": "通过 Webhook 触发时显示一次性视觉效果(闪烁、脉冲、扫描)。设计为组合源中的叠加层。", - "color_strip.notification.os_listener": "监听系统通知:", - "color_strip.notification.os_listener.hint": "启用后,当桌面通知出现时(Windows toast / Linux D-Bus),此源会自动触发。需要应用具有通知访问权限。", - "color_strip.notification.effect": "效果:", - "color_strip.notification.effect.hint": "通知触发时的视觉效果。闪烁线性衰减,脉冲平滑钟形曲线,扫描从左到右填充后衰减。", - "color_strip.notification.effect.flash": "闪烁", - "color_strip.notification.effect.flash.desc": "瞬时点亮,线性衰减", - "color_strip.notification.effect.pulse": "脉冲", - "color_strip.notification.effect.pulse.desc": "平滑钟形发光", - "color_strip.notification.effect.sweep": "扫描", - "color_strip.notification.effect.sweep.desc": "从左到右填充然后消失", - "color_strip.notification.duration": "持续时间(毫秒):", - "color_strip.notification.duration.hint": "通知效果播放的时长(毫秒)。", - "color_strip.notification.default_color": "默认颜色:", - "color_strip.notification.default_color.hint": "当通知没有应用特定颜色映射时使用的颜色。", - "color_strip.notification.filter_mode": "应用过滤:", - "color_strip.notification.filter_mode.hint": "按应用名称过滤通知。关闭=接受全部,白名单=仅列出的应用,黑名单=排除列出的应用。", - "color_strip.notification.filter_mode.off": "关闭", - "color_strip.notification.filter_mode.whitelist": "白名单", - "color_strip.notification.filter_mode.blacklist": "黑名单", - "color_strip.notification.filter_mode.off.desc": "接受所有通知", - "color_strip.notification.filter_mode.whitelist.desc": "仅列出的应用", - "color_strip.notification.filter_mode.blacklist.desc": "排除列出的应用", - "color_strip.notification.filter_list": "应用列表:", - "color_strip.notification.filter_list.hint": "每行一个应用名称。使用「浏览」从运行中的进程中选择。", - "color_strip.notification.filter_list.placeholder": "Discord\nSlack\nTelegram", - "color_strip.notification.app_colors": "应用颜色", - "color_strip.notification.app_colors.label": "颜色映射:", - "color_strip.notification.app_colors.hint": "每个应用的自定义通知颜色。每行将一个应用名称映射到特定颜色。", - "color_strip.notification.app_colors.add": "+ 添加映射", - "color_strip.notification.app_overrides": "按应用覆盖", - "color_strip.notification.app_overrides.label": "应用覆盖:", - "color_strip.notification.app_overrides.hint": "为特定应用自定义颜色和声音。每行可设置颜色、声音资源和音量。", - "color_strip.notification.app_overrides.add": "+ 添加覆盖", - "color_strip.notification.app_overrides.app_placeholder": "应用名称", - "color_strip.notification.sound": "声音", - "color_strip.notification.sound.asset": "声音资源:", - "color_strip.notification.sound.asset.hint": "选择通知触发时播放的声音资源。留空表示静音。", - "color_strip.notification.sound.none": "无(静音)", - "color_strip.notification.sound.search": "搜索声音…", - "color_strip.notification.sound.volume": "音量:", - "color_strip.notification.sound.volume.hint": "通知声音的全局音量(0–100%)。", - "color_strip.notification.sound.app_sounds": "按应用声音:", - "color_strip.notification.sound.app_sounds.hint": "为特定应用覆盖声音和音量。空声音 = 静音该应用。", - "color_strip.notification.sound.app_sounds.add": "+ 添加覆盖", - "color_strip.notification.sound.app_name_placeholder": "应用名称", - "color_strip.notification.endpoint": "Webhook 端点:", - "color_strip.notification.endpoint.hint": "使用此 URL 从外部系统触发通知。POST 请求可选 JSON:{\"app\": \"AppName\", \"color\": \"#FF0000\"}。", - "color_strip.notification.save_first": "请先保存源以查看 Webhook 端点 URL。", - "color_strip.notification.app_count": "个应用", - "color_strip.notification.test": "测试通知", - "color_strip.notification.test.ok": "通知已发送", - "color_strip.notification.test.no_streams": "此源没有运行中的流", - "color_strip.notification.test.error": "发送通知失败", - "color_strip.notification.history.title": "通知历史", - "color_strip.notification.history.hint": "监听器捕获的最近OS通知(最新在前),最多50条。", - "color_strip.notification.history.empty": "尚未捕获任何通知", - "color_strip.notification.history.unavailable": "此平台不支持OS通知监听器", - "color_strip.notification.history.error": "加载通知历史失败", - "color_strip.notification.history.refresh": "刷新", - "color_strip.notification.history.unknown_app": "未知应用", - "color_strip.notification.history.fired": "触发的流数量", - "color_strip.notification.history.filtered": "过滤的流数量", - "color_strip.test.title": "预览测试", - "color_strip.test.connecting": "连接中...", - "color_strip.test.error": "无法连接到预览流", - "color_strip.test.led_count": "LED数量:", - "color_strip.test.fps": "FPS:", - "color_strip.test.receive_fps": "接收帧率", - "color_strip.test.apply": "应用", - "color_strip.test.composite": "合成", - "color_strip.preview.title": "实时预览", - "color_strip.preview.not_connected": "未连接", - "color_strip.preview.connecting": "连接中...", - "color_strip.preview.connected": "已连接", - "color_strip.preview.unsupported": "此源类型不支持预览", - "color_strip.preview.save_first": "请先保存源——预览需要校准数据", - "color_strip.type.daylight": "日光循环", - "color_strip.type.daylight.desc": "模拟24小时自然日光变化", - "color_strip.type.daylight.hint": "模拟太阳在24小时内的色温变化——从温暖的日出到冷白的日光,再到温暖的日落和昏暗的夜晚。", - "color_strip.daylight.speed": "速度:", - "color_strip.daylight.speed.hint": "循环速度倍数。1.0 = 约4分钟完成一个完整的昼夜循环。", - "color_strip.daylight.use_real_time": "使用实时时间:", - "color_strip.daylight.use_real_time.hint": "启用后,LED颜色匹配计算机的实际时间。速度设置将被忽略。", - "color_strip.daylight.real_time": "实时", - "color_strip.daylight.latitude": "纬度:", - "color_strip.daylight.latitude.hint": "地理纬度(-90到90)。影响实时模式下的日出/日落时间。", - "color_strip.type.candlelight": "烛光", - "color_strip.type.candlelight.desc": "逼真的烛光闪烁模拟", - "color_strip.type.candlelight.hint": "在所有LED上模拟逼真的蜡烛闪烁,具有温暖色调和有机闪烁模式。", - "color_strip.type.weather": "天气", - "color_strip.type.weather.desc": "天气感应环境色彩", - "color_strip.type.weather.hint": "将实时天气状况映射为环境LED颜色。需要天气源实体。", - "color_strip.weather.source": "天气源:", - "color_strip.weather.source.hint": "用于环境颜色的天气数据源。请先在天气标签页中创建。", - "color_strip.weather.speed": "动画速度:", - "color_strip.weather.speed.hint": "环境色彩漂移动画速度。越高越快。", - "color_strip.weather.temperature_influence": "温度影响:", - "color_strip.weather.temperature_influence.hint": "当前温度对调色板冷暖偏移程度。0=纯天气颜色,1=强偏移。", - "color_strip.weather.error.no_source": "请选择天气源", - "color_strip.candlelight.color": "基础颜色:", - "color_strip.candlelight.color.hint": "蜡烛火焰的温暖基础颜色。默认为自然温暖的琥珀色。", - "color_strip.candlelight.intensity": "闪烁强度:", - "color_strip.candlelight.intensity.hint": "蜡烛闪烁程度。低值产生柔和光芒,高值模拟风中的蜡烛。", - "color_strip.candlelight.num_candles_label": "蜡烛数量:", - "color_strip.candlelight.num_candles": "支蜡烛", - "color_strip.candlelight.num_candles.hint": "灯带上独立蜡烛光源的数量。每支蜡烛有自己的闪烁模式。", - "color_strip.candlelight.speed": "闪烁速度:", - "color_strip.candlelight.speed.hint": "闪烁动画的速度。较高的值产生更快、更不安定的火焰。", - "color_strip.type.processed": "已处理", - "color_strip.type.processed.desc": "将处理模板应用于另一个源", - "color_strip.type.processed.hint": "包装现有色带源并通过滤镜链处理其输出。", - "color_strip.processed.input": "源:", - "color_strip.processed.input.hint": "将被处理的色带源", - "color_strip.processed.template": "处理模板:", - "color_strip.processed.template.hint": "应用于输入源输出的滤镜链", - "color_strip.processed.error.no_input": "请选择输入源", - "color_strip.composite.layers": "图层:", - "color_strip.composite.layers.hint": "叠加多个色带源。第一个图层在底部,最后一个在顶部。每个图层可以有自己的混合模式和不透明度。", - "color_strip.composite.add_layer": "添加图层", - "color_strip.composite.source": "源", - "color_strip.composite.blend_mode": "混合", - "color_strip.composite.blend_mode.normal": "正常", - "color_strip.composite.blend_mode.normal.desc": "标准 Alpha 混合", - "color_strip.composite.blend_mode.add": "叠加", - "color_strip.composite.blend_mode.add.desc": "通过叠加颜色提亮", - "color_strip.composite.blend_mode.multiply": "正片叠底", - "color_strip.composite.blend_mode.multiply.desc": "通过相乘颜色变暗", - "color_strip.composite.blend_mode.screen": "滤色", - "color_strip.composite.blend_mode.screen.desc": "提亮,正片叠底的反转", - "color_strip.composite.blend_mode.override": "覆盖", - "color_strip.composite.blend_mode.overlay": "叠加", - "color_strip.composite.blend_mode.overlay.desc": "暗部相乘,亮部滤色", - "color_strip.composite.blend_mode.soft_light": "柔光", - "color_strip.composite.blend_mode.soft_light.desc": "柔和对比度调整", - "color_strip.composite.blend_mode.hard_light": "强光", - "color_strip.composite.blend_mode.hard_light.desc": "强对比度,鲜艳色彩", - "color_strip.composite.blend_mode.difference": "差值", - "color_strip.composite.blend_mode.difference.desc": "绝对颜色差异", - "color_strip.composite.blend_mode.exclusion": "排除", - "color_strip.composite.blend_mode.exclusion.desc": "类似差值,对比度更低", - "color_strip.composite.blend_mode.override.desc": "黑色=透明,亮色=不透明", - "color_strip.composite.opacity": "不透明度", - "color_strip.composite.brightness": "亮度", - "color_strip.composite.brightness.none": "无(全亮度)", - "color_strip.composite.processing": "处理", - "color_strip.composite.enabled": "启用", - "color_strip.composite.error.min_layers": "至少需要 1 个图层", - "color_strip.composite.error.no_source": "每个图层必须选择一个源", - "color_strip.composite.layers_count": "个图层", - "color_strip.composite.range": "LED范围", - "color_strip.composite.range_start": "起始", - "color_strip.composite.range_end": "结束", - "color_strip.composite.reverse": "反转", - "color_strip.mapped.zones": "区域:", - "color_strip.mapped.zones.hint": "每个区域将色带源映射到特定 LED 范围。区域并排放置 — 区域之间的间隙保持黑色。", - "color_strip.mapped.add_zone": "+ 添加区域", - "color_strip.mapped.zone_source": "源", - "color_strip.mapped.zone_start": "起始 LED", - "color_strip.mapped.zone_end": "结束 LED", - "color_strip.mapped.zone_reverse": "反转", - "color_strip.mapped.zones_count": "个区域", - "color_strip.mapped.select_source": "搜索源...", - "color_strip.mapped.error.no_source": "每个区域必须选择一个源", - "color_strip.audio.visualization": "可视化:", - "color_strip.audio.visualization.hint": "音频数据如何渲染到 LED。", - "color_strip.audio.viz.spectrum": "频谱分析", - "color_strip.audio.viz.spectrum.desc": "频率条分布在灯带上", - "color_strip.audio.viz.beat_pulse": "节拍脉冲", - "color_strip.audio.viz.beat_pulse.desc": "所有LED随节拍脉动", - "color_strip.audio.viz.vu_meter": "VU 表", - "color_strip.audio.viz.vu_meter.desc": "音量填充灯带", - "color_strip.audio.viz.pulse_on_beat": "节拍脉动", - "color_strip.audio.viz.pulse_on_beat.desc": "LED随每个检测到的节拍脉动", - "color_strip.audio.viz.energy_gradient": "能量渐变", - "color_strip.audio.viz.energy_gradient.desc": "渐变强度跟随音频能量", - "color_strip.audio.viz.spectrum_bands": "频谱频段", - "color_strip.audio.viz.spectrum_bands.desc": "分组频段分布在灯带上", - "color_strip.audio.viz.strobe_on_drop": "低音闪烁", - "color_strip.audio.viz.strobe_on_drop.desc": "低音下降时闪烁频闪效果", - "color_strip.audio.beat_decay": "节拍衰减:", - "color_strip.audio.beat_decay.hint": "节拍脉冲消退的速度。较低值 = 较长衰减,较高值 = 更灵敏的响应。", - "color_strip.audio.source": "音频源:", - "color_strip.audio.source.hint": "此可视化的音频源。可以是多声道(设备)或单声道(单通道)源。在源标签页中创建和管理音频源。", - "color_strip.audio.sensitivity": "灵敏度:", - "color_strip.audio.sensitivity.hint": "音频电平的增益倍数。更高值使 LED 对较安静的声音也有反应。", - "color_strip.audio.smoothing": "平滑:", - "color_strip.audio.smoothing.hint": "帧间时间平滑。更高值产生更平滑但反应较慢的视觉效果。", - "color_strip.audio.palette": "调色板:", - "color_strip.audio.palette.hint": "用于频谱条或节拍脉冲着色的调色板。", - "color_strip.audio.color": "基础颜色:", - "color_strip.audio.color.hint": "VU 表条的低电平颜色。", - "color_strip.audio.color_peak": "峰值颜色:", - "color_strip.audio.color_peak.hint": "VU 表条顶部的高电平颜色。", - "color_strip.audio.mirror": "镜像:", - "color_strip.audio.mirror.hint": "从中心向外镜像频谱:低音在中间,高音在两端。", - "color_strip.effect.type": "效果类型:", - "color_strip.effect.type.hint": "选择程序化算法。", - "color_strip.effect.fire": "火焰", - "color_strip.effect.fire.desc": "模拟带热量扩散的上升火焰的元胞自动机", - "color_strip.effect.meteor": "流星", - "color_strip.effect.meteor.desc": "明亮头部沿灯带移动,带指数衰减的尾迹", - "color_strip.effect.plasma": "等离子", - "color_strip.effect.plasma.desc": "映射到调色板的重叠正弦波 — 经典演示场景效果", - "color_strip.effect.noise": "噪声", - "color_strip.effect.noise.desc": "滚动的分形值噪声映射到调色板", - "color_strip.effect.aurora": "极光", - "color_strip.effect.aurora.desc": "漂移和混合的分层噪声带 — 北极光风格", - "color_strip.effect.speed": "速度:", - "color_strip.effect.speed.hint": "效果动画的速度倍数(0.1 = 非常慢,10.0 = 非常快)。", - "color_strip.effect.palette": "调色板:", - "color_strip.effect.palette.hint": "用于将效果值映射到 RGB 颜色的调色板。", - "color_strip.effect.color": "流星颜色:", - "color_strip.effect.color.hint": "流星效果的头部颜色。", - "color_strip.effect.intensity": "强度:", - "color_strip.effect.intensity.hint": "效果强度 — 控制火花率(火焰)、尾迹衰减(流星)或亮度范围(极光)。", - "color_strip.effect.scale": "比例:", - "color_strip.effect.scale.hint": "空间比例 — 波频率(等离子)、缩放级别(噪声)或带宽(极光)。", - "color_strip.effect.mirror": "镜像:", - "color_strip.effect.mirror.hint": "反弹模式 — 流星在灯带末端反转方向而不是循环。", - "color_strip.palette.fire": "火焰", - "color_strip.palette.ocean": "海洋", - "color_strip.palette.lava": "熔岩", - "color_strip.palette.forest": "森林", - "color_strip.palette.rainbow": "彩虹", - "color_strip.palette.aurora": "极光", - "color_strip.palette.sunset": "日落", - "color_strip.palette.ice": "冰", - "audio_source.title": "音频源", - "audio_source.add": "添加音频源", - "audio_source.add.capture": "添加捕获源", - "audio_source.add.processed": "添加处理源", - "audio_source.edit": "编辑音频源", - "audio_source.edit.capture": "编辑捕获源", - "audio_source.edit.processed": "编辑处理源", - "audio_source.name": "名称:", - "audio_source.name.placeholder": "系统音频", - "audio_source.name.hint": "此音频源的描述性名称", - "audio_source.type": "类型:", - "audio_source.type.hint": "捕获包装物理音频设备。处理将音频处理滤波器应用于另一个源。", - "audio_source.type.capture": "捕获", - "audio_source.type.processed": "已处理", - "audio_source.device": "音频设备:", - "audio_source.device.hint": "音频输入源。回环设备采集系统音频输出;输入设备采集麦克风或线路输入。", - "audio_source.refresh_devices": "刷新设备", - "audio_source.parent": "输入音频源:", - "audio_source.parent.hint": "要应用处理滤波器的音频源", - "audio_source.processing_template": "处理模板:", - "audio_source.processing_template.hint": "包含要应用于输入源的滤波器的音频处理模板", - "audio_source.description": "描述(可选):", - "audio_source.description.placeholder": "描述此音频源...", - "audio_source.description.hint": "关于此音频源的可选说明", - "audio_source.created": "音频源已创建", - "audio_source.updated": "音频源已更新", - "audio_source.deleted": "音频源已删除", - "audio_source.delete.confirm": "确定要删除此音频源吗?", - "audio_source.error.name_required": "请输入名称", - "audio_source.audio_template": "音频模板:", - "audio_source.audio_template.hint": "定义此设备使用哪个引擎和设置的音频采集模板", - "audio_source.test": "测试", - "audio_source.test.title": "测试音频源", - "audio_source.test.rms": "RMS", - "audio_source.test.peak": "峰值", - "audio_source.test.beat": "节拍", - "audio_source.test.connecting": "连接中...", - "audio_source.test.error": "音频测试失败", - "audio_template.test": "测试", - "audio_template.test.title": "测试音频模板", - "audio_template.test.device": "音频设备:", - "audio_template.test.device.hint": "选择测试期间要采集的音频设备", - "audio_template.test.run": "运行", - "audio_template.title": "音频模板", - "audio_template.add": "添加音频模板", - "audio_template.edit": "编辑音频模板", - "audio_template.name": "模板名称:", - "audio_template.name.placeholder": "我的音频模板", - "audio_template.description.label": "描述(可选):", - "audio_template.description.placeholder": "描述此模板...", - "audio_template.engine": "音频引擎:", - "audio_template.engine.hint": "选择要使用的音频采集后端。WASAPI 仅限 Windows 并支持回环。Sounddevice 支持跨平台。", - "audio_template.engine.unavailable": "不可用", - "audio_template.engine.unavailable.hint": "此引擎在您的系统上不可用", - "audio_template.config": "配置", - "audio_template.config.show": "显示配置", - "audio_template.created": "音频模板已创建", - "audio_template.updated": "音频模板已更新", - "audio_template.deleted": "音频模板已删除", - "audio_template.delete.confirm": "确定要删除此音频模板吗?", - "audio_template.error.load": "加载音频模板失败", - "audio_template.error.engines": "加载音频引擎失败", - "audio_template.error.required": "请填写所有必填项", - "audio_template.error.delete": "删除音频模板失败", - "audio_template.error.save_failed": "保存音频模板失败", - "audio_template.error.load_failed": "加载音频模板失败", - "gradient.error.save_failed": "保存渐变失败", - "gradient.error.delete_failed": "删除渐变失败", - "streams.group.value": "值源", - "streams.group.sync": "同步时钟", - "tree.group.picture": "图片源", - "tree.group.capture": "屏幕采集", - "tree.group.static": "静态", - "tree.group.processing": "已处理", - "tree.group.strip": "色带", - "tree.group.audio": "音频", - "tree.group.audio_capture": "采集", - "tree.group.audio_processed": "处理", - "tree.group.utility": "工具", - "tree.leaf.sources": "源", - "tree.leaf.engine_templates": "引擎模板", - "tree.leaf.images": "图片", - "tree.leaf.video": "视频", - "tree.leaf.filter_templates": "滤镜模板", - "tree.leaf.processing_templates": "处理模板", - "tree.leaf.templates": "模板", - "value_source.group.title": "值源", - "value_source.select_type": "选择值源类型", - "value_source.add": "添加值源", - "value_source.edit": "编辑值源", - "value_source.name": "名称:", - "value_source.name.placeholder": "亮度脉冲", - "value_source.name.hint": "此值源的描述性名称", - "value_source.type": "类型:", - "value_source.type.hint": "静态输出固定值。动画循环波形。音频响应声音输入。自适应类型根据时间或场景内容自动调节亮度。", - "value_source.type.static": "静态", - "value_source.type.static.desc": "固定输出值", - "value_source.type.animated": "动画", - "value_source.type.animated.desc": "循环波形变化", - "value_source.type.audio": "音频", - "value_source.type.audio.desc": "响应声音输入", - "value_source.type.adaptive_time": "自适应(时间)", - "value_source.type.adaptive_time.desc": "按时间自动调节", - "value_source.type.adaptive_scene": "自适应(场景)", - "value_source.type.adaptive_scene.desc": "按场景内容调节", - "value_source.type.daylight": "日光周期", - "value_source.type.daylight.desc": "数值跟随昼夜周期", - "value_source.daylight.speed": "速度:", - "value_source.daylight.speed.hint": "周期速度倍率。1.0 = 完整日夜周期约4分钟。", - "value_source.daylight.use_real_time": "使用实时:", - "value_source.daylight.use_real_time.hint": "启用后,数值跟随实际时间。速度设置将被忽略。", - "value_source.normalize": "归一化到 0–1:", - "value_source.normalize.hint": "开启:使用最小/最大值将原始值缩放到 0–1。关闭:直接将数值钳制到 0–1(适用于本身就输出 0–1 比例的来源)。原始值始终可在模板(raw[name])和自动化中使用。", - "value_source.daylight.enable_real_time": "跟随系统时钟", - "value_source.daylight.latitude": "纬度:", - "value_source.daylight.latitude.hint": "地理纬度(-90到90)。使日出/日落过渡更陡峭或更平缓。", - "value_source.daylight.longitude": "经度:", - "value_source.daylight.longitude.hint": "地理经度(-180到180)。偏移正午时刻,使日出/日落与挂钟时间对齐。", - "value_source.daylight.real_time": "实时", - "value_source.daylight.speed_label": "速度", - "value_source.value": "值:", - "value_source.value.hint": "固定输出值(0.0 = 关闭,1.0 = 最大亮度)", - "value_source.waveform": "波形:", - "value_source.waveform.hint": "亮度动画循环的形状", - "value_source.waveform.sine": "正弦", - "value_source.waveform.triangle": "三角", - "value_source.waveform.square": "方波", - "value_source.waveform.sawtooth": "锯齿", - "value_source.speed": "速度(周期/分):", - "value_source.speed.hint": "每分钟周期数 — 波形重复的速度(1 = 非常慢,120 = 非常快)", - "value_source.min_value": "最小值:", - "value_source.min_value.hint": "波形周期的最小输出", - "value_source.max_value": "最大值:", - "value_source.max_value.hint": "波形周期的最大输出", - "value_source.audio_source": "音频源:", - "value_source.audio_source.hint": "要读取音频电平的音频源", - "value_source.mode": "模式:", - "value_source.mode.hint": "RMS 测量平均音量。峰值跟踪最响的时刻。节拍在节奏上触发。", - "value_source.mode.rms": "RMS(音量)", - "value_source.mode.peak": "峰值", - "value_source.mode.beat": "节拍", - "value_source.mode.rms.desc": "平均音量水平", - "value_source.mode.peak.desc": "最响时刻追踪", - "value_source.mode.beat.desc": "节奏脉冲检测", - "value_source.auto_gain": "自动增益:", - "value_source.auto_gain.hint": "自动归一化音频电平,使输出使用完整范围,无论输入音量大小", - "value_source.auto_gain.enable": "启用自动增益", - "value_source.sensitivity": "灵敏度:", - "value_source.sensitivity.hint": "音频信号的增益倍数(越高反应越灵敏)", - "value_source.scene_sensitivity.hint": "亮度信号的增益倍数(越高对亮度变化越敏感)", - "value_source.smoothing": "平滑:", - "value_source.smoothing.hint": "时间平滑(0 = 即时响应,1 = 非常平滑/缓慢)", - "value_source.audio_min_value": "最小值:", - "value_source.audio_min_value.hint": "音频静默时的输出(例如 0.3 = 30% 亮度下限)", - "value_source.audio_max_value": "最大值:", - "value_source.audio_max_value.hint": "最大音频电平时的输出", - "value_source.schedule": "计划:", - "value_source.schedule.hint": "定义至少 2 个时间点。亮度在各点之间线性插值,午夜循环。", - "value_source.schedule.add": "+ 添加时间点", - "value_source.schedule.points": "个时间点", - "value_source.picture_source": "图片源:", - "value_source.picture_source.hint": "将分析其帧以获取平均亮度的图片源。", - "value_source.scene_behavior": "行为:", - "value_source.scene_behavior.hint": "互补:暗场景 = 高亮度(适合环境背光)。匹配:亮场景 = 高亮度。", - "value_source.scene_behavior.complement": "互补(暗 → 亮)", - "value_source.scene_behavior.match": "匹配(亮 → 亮)", - "value_source.adaptive_min_value": "最小值:", - "value_source.adaptive_min_value.hint": "最小输出亮度", - "value_source.adaptive_max_value": "最大值:", - "value_source.adaptive_max_value.hint": "最大输出亮度", - "value_source.error.schedule_min": "计划至少需要 2 个时间点", - "value_source.description": "描述(可选):", - "value_source.description.placeholder": "描述此值源...", - "value_source.description.hint": "关于此值源的可选说明", - "value_source.created": "值源已创建", - "value_source.updated": "值源已更新", - "value_source.deleted": "值源已删除", - "value_source.delete.confirm": "确定要删除此值源吗?", - "value_source.error.name_required": "请输入名称", - "value_source.test": "测试", - "value_source.test.title": "测试值源", - "value_source.test.connecting": "连接中...", - "value_source.test.error": "连接失败", - "value_source.test.current": "当前", - "value_source.test.min": "最小", - "value_source.test.max": "最大", - "test.frames": "帧数", - "test.fps": "帧率", - "test.avg_capture": "平均", - "targets.brightness": "亮度:", - "targets.brightness.hint": "输出亮度乘数(0–1)。可绑定到值源进行动态控制。", - "targets.brightness_vs": "亮度源:", - "targets.brightness_vs.hint": "可选的值源,每帧动态控制亮度(覆盖设备亮度)", - "targets.brightness_vs.none": "无(设备亮度)", - "targets.min_brightness_threshold": "最低亮度阈值:", - "targets.min_brightness_threshold.hint": "当有效输出亮度(像素亮度 × 设备/源亮度)低于此值时,LED完全关闭(0 = 禁用)", - "targets.adaptive_fps": "自适应FPS:", - "targets.adaptive_fps.hint": "当设备无响应时自动降低发送速率,稳定后逐步恢复。推荐用于信号较弱的WiFi设备。", - "targets.protocol": "协议:", - "targets.protocol.hint": "DDP通过快速UDP发送像素(推荐)。HTTP使用JSON API——较慢但可靠,限制约500个LED。", - "targets.power_limit": "最大电流 (ABL):", - "targets.power_limit.hint": "将灯带的估算电流限制在电源预算内,以防止明亮/白色场景下的电压骤降(颜色偏移、闪烁、重启)。请设为电源的额定电流并留有余量。0 = 不限制。", - "targets.power_limit.ma_suffix": "mA(0 = 不限制)", - "targets.power_limit.per_led": "每颗 LED 电流(全白):", - "targets.protocol.ddp": "DDP (UDP)", - "targets.protocol.ddp.desc": "快速UDP数据包 - 推荐", - "targets.protocol.udp": "WLED UDP(实时)", - "targets.protocol.udp.desc": "WLED 原生实时 — 正确的 RGBW 白色,断流时自动恢复", - "targets.protocol.http": "HTTP", - "targets.protocol.http.desc": "JSON API - 较慢,≤500 LED", - "targets.protocol.serial": "串口", - "search.open": "搜索 (Ctrl+K)", - "search.placeholder": "搜索实体... (Ctrl+K)", - "search.loading": "加载中...", - "search.no_results": "未找到结果", - "search.group.devices": "设备", - "search.group.targets": "LED 目标", - "search.group.kc_targets": "关键颜色目标", - "search.group.css": "色带源", - "search.group.automations": "自动化", - "search.group.streams": "图片流", - "search.group.capture_templates": "采集模板", - "search.group.pp_templates": "后处理模板", - "search.group.pattern_templates": "图案模板", - "search.group.audio": "音频源", - "search.group.value": "值源", - "search.group.scenes": "场景预设", - "search.group.cspt": "色带处理模板", - "search.group.sync_clocks": "同步时钟", - "search.group.actions": "操作", - "search.action.start": "启动", - "search.action.stop": "停止", - "search.action.activate": "激活", - "search.action.enable": "启用", - "search.action.disable": "禁用", - "settings.backup.label": "备份配置", - "settings.backup.hint": "将所有配置(设备、目标、流、模板、自动化)下载为单个 JSON 文件。", - "settings.backup.button": "下载", - "settings.backup.success": "备份下载成功", - "settings.backup.error": "备份下载失败", - "settings.restore.label": "恢复配置", - "settings.restore.hint": "上传之前下载的备份文件以替换所有配置。服务器将自动重启。", - "settings.restore.button": "恢复", - "settings.restore.confirm": "这将替换所有配置并重启服务器。确定继续吗?", - "settings.restore.success": "配置已恢复", - "settings.restore.error": "恢复失败", - "settings.restore.restarting": "服务器正在重启...", - "settings.restore.restart_timeout": "服务器未响应。请手动刷新页面。", - "settings.restart_server": "重启服务器", - "settings.restart_confirm": "重启服务器?活跃的目标将被停止。", - "settings.restarting": "正在重启服务器...", - "settings.button.close": "关闭", - "settings.log_level.label": "日志级别", - "settings.log_level.hint": "实时更改服务器日志详细程度。DEBUG 显示最多细节;CRITICAL 仅显示致命错误。", - "settings.log_level.save": "应用", - "settings.log_level.saved": "日志级别已更改", - "settings.log_level.save_error": "更改日志级别失败", - "settings.log_level.desc.debug": "详细开发输出", - "settings.log_level.desc.info": "正常运行消息", - "settings.log_level.desc.warning": "潜在问题", - "settings.log_level.desc.error": "仅显示错误", - "settings.log_level.desc.critical": "仅显示致命错误", - "settings.shutdown_action.label": "关机时执行", - "settings.shutdown_action.hint": "服务器关闭时对 LED 目标的处理方式。「停止目标」执行正常的停止流程,启用自动恢复的设备会恢复先前状态。「无」会让灯保持显示最后一帧。", - "settings.shutdown_action.saved": "已保存关机动作", - "settings.shutdown_action.save_error": "保存关机动作失败", - "settings.shutdown_action.opt.stop": "停止目标", - "settings.shutdown_action.opt.stop_desc": "执行正常停止流程(按设备应用自动恢复)", - "settings.shutdown_action.opt.nothing": "无", - "settings.shutdown_action.opt.nothing_desc": "让灯保持最后一帧", - "settings.daylight_timezone.label": "日光周期时区", - "settings.daylight_timezone.hint": "所有“实时”日光周期读取挂钟时间所用的 IANA 时区。留空(默认)则使用服务器的系统时区。", - "settings.daylight_timezone.saved": "日光周期时区已保存", - "settings.daylight_timezone.save_error": "保存日光周期时区失败", - "common.daylight_tz.system": "系统默认", - "common.daylight_tz.system_desc": "服务器时钟 — 不覆盖", - "common.daylight_tz.detected": "浏览器检测", - "common.daylight_tz.detected_label": "自动检测", - "common.daylight_tz.search": "搜索时区、城市、地区…", - "common.daylight_tz.region.utc": "世界标准", - "common.daylight_tz.region.europe": "欧洲", - "common.daylight_tz.region.africa": "非洲", - "common.daylight_tz.region.me": "中东", - "common.daylight_tz.region.asia": "亚洲", - "common.daylight_tz.region.pacific": "太平洋", - "common.daylight_tz.region.americas": "美洲", - "settings.auto_backup.label": "自动备份", - "settings.auto_backup.hint": "自动定期创建所有配置的备份。当达到最大数量时,旧备份会被自动清理。", - "settings.auto_backup.enable": "启用自动备份", - "settings.auto_backup.interval_label": "间隔", - "settings.auto_backup.max_label": "最大备份数", - "settings.auto_backup.save": "保存设置", - "settings.auto_backup.saved": "自动备份设置已保存", - "settings.auto_backup.save_error": "保存自动备份设置失败", - "settings.auto_backup.backup_now": "立即备份", - "settings.auto_backup.backup_created": "备份已创建", - "settings.auto_backup.backup_error": "备份失败", - "settings.auto_backup.last_backup": "上次备份", - "settings.auto_backup.never": "从未", - "settings.saved_backups.label": "已保存的备份", - "settings.saved_backups.hint": "存储在服务器上的自动备份文件。下载到本地保存,或删除以释放空间。", - "settings.saved_backups.empty": "没有已保存的备份", - "settings.saved_backups.restore": "恢复", - "settings.saved_backups.download": "下载", - "settings.saved_backups.delete": "删除", - "settings.saved_backups.delete_confirm": "删除此备份文件?", - "settings.saved_backups.delete_error": "删除备份失败", - "settings.saved_backups.type.auto": "自动", - "settings.saved_backups.type.manual": "手动", - "settings.mqtt.label": "MQTT", - "settings.mqtt.hint": "配置 MQTT 代理连接,用于自动化条件和触发器。", - "settings.mqtt.enabled": "启用 MQTT", - "settings.mqtt.host_label": "代理主机", - "settings.mqtt.port_label": "端口", - "settings.mqtt.username_label": "用户名", - "settings.mqtt.password_label": "密码", - "settings.mqtt.password_set_hint": "已设置密码 — 留空以保留", - "settings.mqtt.client_id_label": "客户端 ID", - "settings.mqtt.base_topic_label": "基础主题", - "settings.mqtt.save": "保存 MQTT 设置", - "settings.mqtt.saved": "MQTT 设置已保存", - "settings.mqtt.save_error": "保存 MQTT 设置失败", - "settings.mqtt.error_host_required": "代理主机不能为空", - "settings.logs.label": "服务器日志", - "settings.logs.hint": "实时查看服务器日志。使用过滤器显示所需的日志级别。", - "settings.logs.connect": "连接", - "settings.logs.disconnect": "断开", - "settings.logs.clear": "清除", - "settings.logs.error": "日志查看器连接失败", - "settings.logs.filter.all": "所有级别", - "settings.logs.filter.info": "Info+", - "settings.logs.filter.warning": "Warning+", - "settings.logs.filter.error": "仅错误", - "settings.logs.filter.all_desc": "显示所有日志消息", - "settings.logs.filter.info_desc": "Info、警告和错误", - "settings.logs.filter.warning_desc": "仅警告和错误", - "settings.logs.filter.error_desc": "仅错误", - "settings.logs.stat.lines": "行数", - "settings.logs.stat.warn": "警告", - "settings.logs.stat.err": "错误", - "settings.logs.patch.idle": "待机", - "settings.logs.patch.connecting": "连接中", - "settings.logs.patch.live": "传输中", - "settings.logs.patch.error": "离线", - "settings.logs.empty.title": "等待日志", - "settings.logs.empty.sub": "连接 WebSocket 流以开始实时跟踪。", - "device.error.power_off_failed": "关闭设备失败", - "device.error.remove_failed": "移除设备失败", - "device.error.settings_load_failed": "加载设备设置失败", - "device.error.brightness": "更新亮度失败", - "device.error.required": "请填写所有字段", - "device.error.update": "更新设备失败", - "device.error.save": "保存设置失败", - "device.error.clone_failed": "克隆设备失败", - "device.error.load_failed": "加载设备失败", - "device_discovery.error.fill_all_fields": "请填写所有字段", - "device_discovery.added": "设备添加成功", - "device_discovery.error.add_failed": "添加设备失败", - "calibration.error.load_failed": "加载校准失败", - "calibration.error.css_load_failed": "加载色带源失败", - "calibration.error.test_toggle_failed": "切换测试边缘失败", - "calibration.error.save_failed": "保存校准失败", - "calibration.error.led_count_mismatch": "LED总数必须等于设备LED数量", - "calibration.error.led_count_exceeded": "校准的LED超过了LED总数", - "calibration.mode.simple": "简单", - "calibration.mode.advanced": "高级", - "calibration.switch_to_advanced": "切换到高级模式", - "calibration.advanced.title": "高级校准", - "calibration.advanced.switch_to_simple": "切换到简单模式", - "calibration.advanced.lines_title": "线段", - "calibration.advanced.canvas_hint": "拖动显示器重新排列。点击边缘选择线段。滚动缩放,拖动空白区域平移。", - "calibration.advanced.reset_view": "重置视图", - "calibration.advanced.line_properties": "线段属性", - "calibration.advanced.picture_source": "来源:", - "calibration.advanced.picture_source.hint": "此线段采样的图片来源(显示器)", - "calibration.advanced.edge": "边缘:", - "calibration.advanced.edge.hint": "从屏幕哪条边缘采样像素", - "calibration.advanced.led_count": "LED数:", - "calibration.advanced.led_count.hint": "映射到此线段的LED数量", - "calibration.advanced.span_start": "起始位置:", - "calibration.advanced.span_start.hint": "沿边缘开始采样的位置(0 = 起点,1 = 终点)。用于仅覆盖边缘的一部分。", - "calibration.advanced.span_end": "结束位置:", - "calibration.advanced.span_end.hint": "沿边缘结束采样的位置(0 = 起点,1 = 终点)。与起始位置一起定义活动区域。", - "calibration.advanced.border_width": "深度(像素):", - "calibration.advanced.border_width.hint": "从边缘向内采样多少像素。较大的值会捕获更多屏幕内部区域。", - "calibration.advanced.reverse": "反转", - "calibration.advanced.no_lines_warning": "请至少添加一条线段", - "dashboard.error.automation_toggle_failed": "切换自动化失败", - "dashboard.error.start_failed": "启动处理失败", - "dashboard.error.stop_failed": "停止处理失败", - "dashboard.error.stop_all": "停止所有目标失败", - "target.error.editor_open_failed": "打开目标编辑器失败", - "target.error.start_failed": "启动目标失败", - "target.error.stop_failed": "停止目标失败", - "target.error.clone_failed": "克隆目标失败", - "target.error.delete_failed": "删除目标失败", - "target.error.load_failed": "加载目标失败", - "targets.stop_all.button": "全部停止", - "targets.stop_all.none_running": "当前没有运行中的目标", - "targets.stop_all.stopped": "已停止 {count} 个目标", - "targets.stop_all.error": "停止目标失败", - "audio_source.error.load": "加载音频源失败", - "audio_template.error.clone_failed": "克隆音频模板失败", - "value_source.error.load": "加载数值源失败", - "color_strip.error.editor_open_failed": "打开色带编辑器失败", - "color_strip.error.clone_failed": "克隆色带源失败", - "color_strip.error.delete_failed": "删除色带源失败", - "pattern.error.editor_open_failed": "打开图案模板编辑器失败", - "pattern.error.clone_failed": "克隆图案模板失败", - "pattern.error.delete_failed": "删除图案模板失败", - "pattern.error.capture_bg_failed": "捕获背景失败", - "pattern.error.save_failed": "保存图案模板失败", - "stream.error.clone_picture_failed": "克隆图片源失败", - "stream.error.clone_capture_failed": "克隆捕获模板失败", - "stream.error.clone_pp_failed": "克隆后处理模板失败", - "theme.switched.dark": "已切换到深色主题", - "theme.switched.light": "已切换到浅色主题", - "theme.switched.system": "已切换到系统主题", - "accent.color.updated": "强调色已更新", - "search.footer": "↑↓ 导航 · Enter 选择 · Esc 关闭", - "sync_clock.group.title": "同步时钟", - "sync_clock.add": "添加同步时钟", - "sync_clock.edit": "编辑同步时钟", - "sync_clock.name": "名称:", - "sync_clock.name.placeholder": "主动画时钟", - "sync_clock.name.hint": "此同步时钟的描述性名称", - "sync_clock.speed": "速度:", - "sync_clock.speed.hint": "所有关联源的动画速度倍率。1.0 = 正常,2.0 = 双倍,0.5 = 半速。", - "sync_clock.description": "描述(可选):", - "sync_clock.description.placeholder": "可选描述", - "sync_clock.description.hint": "关于此时钟用途的可选备注", - "sync_clock.status.running": "运行中", - "sync_clock.status.paused": "已暂停", - "sync_clock.action.pause": "暂停", - "sync_clock.action.resume": "恢复", - "sync_clock.action.reset": "重置", - "sync_clock.error.name_required": "时钟名称为必填项", - "sync_clock.error.load": "加载同步时钟失败", - "sync_clock.created": "同步时钟已创建", - "sync_clock.updated": "同步时钟已更新", - "sync_clock.deleted": "同步时钟已删除", - "sync_clock.paused": "时钟已暂停", - "sync_clock.resumed": "时钟已恢复", - "sync_clock.reset_done": "时钟已重置为零", - "sync_clock.delete.confirm": "删除此同步时钟?关联的源将失去同步并以默认速度运行。", - "sync_clock.elapsed": "已用时间", - "weather_source.group.title": "天气源", - "weather_source.add": "添加天气源", - "weather_source.edit": "编辑天气源", - "weather_source.name": "名称:", - "weather_source.name.placeholder": "我的天气", - "weather_source.name.hint": "天气数据源的描述性名称", - "weather_source.provider": "提供商:", - "weather_source.provider.hint": "天气数据提供商。Open-Meteo免费且无需API密钥。", - "weather_source.provider.open_meteo.desc": "免费,无需API密钥", - "weather_source.location": "位置:", - "weather_source.location.hint": "您的地理坐标。使用自动检测按钮或手动输入。", - "weather_source.latitude": "纬度:", - "weather_source.longitude": "经度:", - "weather_source.use_my_location": "使用我的位置", - "weather_source.update_interval": "更新间隔:", - "weather_source.update_interval.hint": "获取天气数据的频率。值越低,更新越及时。", - "weather_source.description": "描述(可选):", - "weather_source.description.placeholder": "可选描述", - "weather_source.test": "测试", - "weather_source.error.name_required": "天气源名称为必填项", - "weather_source.error.load": "加载天气源失败", - "weather_source.created": "天气源已创建", - "weather_source.updated": "天气源已更新", - "weather_source.deleted": "天气源已删除", - "weather_source.delete.confirm": "删除此天气源?关联的色带源将失去天气数据。", - "weather_source.geo.success": "位置已检测", - "weather_source.geo.error": "地理定位失败", - "weather_source.geo.not_supported": "您的浏览器不支持地理定位", - "streams.group.weather": "天气", - "section.empty.weather_sources": "暂无天气源。点击+添加。", - "color_strip.clock": "同步时钟:", - "color_strip.clock.hint": "关联同步时钟以在多个源之间同步动画。速度在时钟上控制。", - "graph.title": "图表", - "graph.fit_all": "显示所有节点", - "graph.zoom_in": "放大", - "graph.zoom_out": "缩小", - "graph.search": "搜索节点", - "graph.search_placeholder": "搜索实体...", - "graph.legend": "图例", - "graph.minimap": "小地图", - "graph.relayout": "重新布局", - "graph.empty": "暂无实体", - "graph.empty.hint": "创建设备、源和目标后即可在此查看。", - "graph.disconnect": "断开连接", - "graph.connection_updated": "连接已更新", - "graph.connection_failed": "更新连接失败", - "graph.connection_removed": "连接已移除", - "graph.disconnect_failed": "断开连接失败", - "graph.relayout_confirm": "重置所有手动节点位置并重新布局图表?", - "graph.fullscreen": "切换全屏", - "graph.add_entity": "添加实体", - "graph.color_picker": "节点颜色", - "graph.filter": "筛选节点", - "graph.filter_placeholder": "按名称筛选...", - "graph.filter_clear": "清除筛选", - "graph.filter_running": "运行中", - "graph.filter_stopped": "已停止", - "graph.filter_types": "类型", - "graph.filter_group.capture": "捕获", - "graph.filter_group.strip": "色带", - "graph.filter_group.audio": "音频", - "graph.filter_group.targets": "目标", - "graph.filter_group.other": "其他", - "graph.bulk_delete_confirm": "删除 {count} 个选中的实体?", - "graph.nothing_to_undo": "没有可撤销的操作", - "graph.nothing_to_redo": "没有可重做的操作", - "graph.help_title": "键盘快捷键", - "graph.help.search": "搜索", - "graph.help.filter": "筛选", - "graph.help.add": "添加实体", - "graph.help.shortcuts": "快捷键", - "graph.help.delete": "删除 / 断开", - "graph.help.select_all": "全选", - "graph.help.undo": "撤销", - "graph.help.redo": "重做", - "graph.help.fullscreen": "全屏", - "graph.help.deselect": "取消选择", - "graph.help.navigate": "节点导航", - "graph.help.click": "单击", - "graph.help.click_desc": "选择节点", - "graph.help.dblclick": "双击", - "graph.help.dblclick_desc": "缩放到节点", - "graph.help.shift_click": "Shift+单击", - "graph.help.shift_click_desc": "多选", - "graph.help.shift_drag": "Shift+拖拽", - "graph.help.shift_drag_desc": "框选", - "graph.help.drag_node": "拖拽节点", - "graph.help.drag_node_desc": "重新定位", - "graph.help.drag_port": "拖拽端口", - "graph.help.drag_port_desc": "连接实体", - "graph.help.right_click": "右键边线", - "graph.help.right_click_desc": "断开连接", - "graph.tooltip.fps": "帧率", - "graph.tooltip.errors": "错误", - "graph.tooltip.uptime": "运行时间", - "graph.undone": "已撤销", - "graph.redone": "已重做", - "graph.action.connect": "连接", - "graph.action.disconnect": "断开连接", - "graph.action.move": "移动节点", - "graph.action.rewire": "重新连接槽位", - "graph.choose_connection": "选择连接", - "graph.rewire": "重新连接…", - "graph.rewire_choose_source": "选择新的来源", - "graph.issues": "问题", - "graph.issues_none": "未发现问题", - "graph.issue.broken_ref": "无效引用:{field}", - "graph.issue.cycle": "属于依赖循环", - "graph.replace_connection_confirm": "替换现有连接?", - "graph.no_compatible_connection": "这些实体之间没有兼容的连接", - "graph.create_and_connect": "创建并连接…", - "graph.export": "导出图谱 (JSON)", - "graph.export_done": "图谱已导出", - "graph.export_failed": "导出图谱失败", - "graph.duplicate": "复制所选", - "graph.duplicate_none": "请选择一个或多个节点以复制", - "graph.duplicate_none_eligible": "所选内容中没有可复制的项(仅值源和色带源)", - "graph.duplicate_done": "已复制 {count} 个源", - "graph.duplicate_done_warn": "已复制 {count} 个源 — 部分引用无法重新映射", - "graph.duplicate_failed": "复制所选失败", - "graph.delete_with_dependents_confirm": "此实体被 {count} 个其他实体引用:{names}。删除并断开这些连接?", - "automation.enabled": "自动化已启用", - "automation.disabled": "自动化已禁用", - "scene_preset.activated": "预设已激活", - "scene_preset.used_by": "被 %d 个自动化使用", - "settings.api_keys.label": "API 密钥", - "settings.api_keys.hint": "API 密钥在服务器配置文件 (config.yaml) 中定义。编辑文件并重启服务器以应用更改。", - "settings.api_keys.empty": "未配置 API 密钥", - "settings.api_keys.load_error": "加载 API 密钥失败", - "settings.partial.label": "部分导出 / 导入", - "settings.partial.hint": "导出或导入单个实体类型。导入会替换或合并现有数据并重启服务器。", - "settings.partial.store.devices": "设备", - "settings.partial.store.output_targets": "LED 目标", - "settings.partial.store.color_strip_sources": "色带", - "settings.partial.store.picture_sources": "图像源", - "settings.partial.store.audio_sources": "音频源", - "settings.partial.store.audio_templates": "音频模板", - "settings.partial.store.capture_templates": "捕获模板", - "settings.partial.store.postprocessing_templates": "后处理模板", - "settings.partial.store.color_strip_processing_templates": "CSS 处理模板", - "settings.partial.store.pattern_templates": "图案模板", - "settings.partial.store.value_sources": "值源", - "settings.partial.store.sync_clocks": "同步时钟", - "settings.partial.store.automations": "自动化", - "settings.partial.store.scene_presets": "场景预设", - "settings.partial.export_button": "导出", - "settings.partial.import_button": "从文件导入", - "settings.partial.merge_label": "合并(添加/覆盖,保留现有)", - "settings.partial.export_success": "导出成功", - "settings.partial.export_error": "导出失败", - "settings.partial.import_success": "导入成功", - "settings.partial.import_error": "导入失败", - "settings.partial.import_confirm_replace": "这将替换所有 {store} 数据并重启服务器。继续吗?", - "settings.partial.import_confirm_merge": "这将合并 {store} 数据并重启服务器。继续吗?", - "section.empty.devices": "暂无设备。点击 + 添加。", - "section.empty.targets": "暂无 LED 目标。点击 + 添加。", - "section.empty.kc_targets": "暂无键色目标。点击 + 添加。", - "section.empty.pattern_templates": "暂无图案模板。点击 + 添加。", - "section.empty.picture_sources": "暂无源。点击 + 添加。", - "section.empty.capture_templates": "暂无捕获模板。点击 + 添加。", - "section.empty.pp_templates": "暂无后处理模板。点击 + 添加。", - "section.empty.audio_sources": "暂无音频源。点击 + 添加。", - "section.empty.audio_templates": "暂无音频模板。点击 + 添加。", - "section.empty.color_strips": "暂无色带。点击 + 添加。", - "section.empty.value_sources": "暂无值源。点击 + 添加。", - "section.empty.sync_clocks": "暂无同步时钟。点击 + 添加。", - "section.empty.cspt": "暂无 CSS 处理模板。点击 + 添加。", - "section.empty.automations": "暂无自动化。点击 + 添加。", - "section.empty.scenes": "暂无场景预设。点击 + 添加。", - "section.empty.playlists": "暂无播放列表。点击 + 添加。", - "bulk.select": "选择", - "bulk.cancel": "取消", - "bulk.selected_count.one": "已选 {count} 项", - "bulk.selected_count.other": "已选 {count} 项", - "bulk.select_all": "全选", - "bulk.deselect_all": "取消全选", - "bulk.delete": "删除", - "bulk.start": "启动", - "bulk.stop": "停止", - "bulk.enable": "启用", - "bulk.disable": "禁用", - "bulk.confirm_delete.one": "删除 {count} 项?", - "bulk.confirm_delete.other": "删除 {count} 项?", - "appearance.style.label": "样式预设", - "appearance.style.hint": "选择一个视觉主题 — 字体和配色方案一起应用。", - "appearance.preset.default": "默认", - "appearance.preset.midnight": "午夜", - "appearance.preset.ember": "余烬", - "appearance.preset.arctic": "极地", - "appearance.preset.terminal": "终端", - "appearance.preset.neon": "霓虹", - "appearance.preset.sakura": "樱花", - "appearance.preset.ocean": "海洋", - "appearance.preset.copper": "铜色", - "appearance.preset.vapor": "蒸汽波", - "appearance.preset.monolith": "黑白", - "appearance.preset.applied": "样式已应用", - "appearance.bg.label": "背景效果", - "appearance.bg.hint": "在界面后面添加环境背景层。", - "appearance.bg.none": "无", - "appearance.bg.noise": "噪声场", - "appearance.bg.aurora": "极光", - "appearance.bg.plasma": "等离子", - "appearance.bg.rain": "数字雨", - "appearance.bg.stars": "星空", - "appearance.bg.warp": "隧道", - "appearance.bg.grid": "点阵", - "appearance.bg.mesh": "渐变网格", - "appearance.bg.scanlines": "扫描线", - "appearance.bg.applied": "背景效果已应用", - "settings.tab.notifications": "通知", - "settings.tab.updates": "更新", - "settings.tab.about": "关于", - "settings.notifications.intro_label": "设备事件", - "settings.notifications.intro_hint": "选择每类事件的通知方式。「内置」在页面底部弹出提示,「系统」显示系统通知(需要浏览器授权),「两者」同时显示,「关闭」则不通知。", - "settings.notifications.row.online": "设备重新上线", - "settings.notifications.row.offline": "设备离线", - "settings.notifications.row.discovered": "发现新设备", - "settings.notifications.row.lost": "已发现的设备消失", - "settings.notifications.background.label": "后台发现", - "settings.notifications.background.hint": "持续扫描局域网(mDNS)和串口以发现新的 LED 设备。关闭后将不再产生「发现/消失」事件。重启服务器后生效。", - "settings.notifications.background.toggle": "启用后台发现", - "settings.notifications.permission.label": "系统通知权限", - "settings.notifications.permission.grant": "授权", - "settings.notifications.permission.granted": "系统通知已启用", - "settings.notifications.permission.denied": "系统通知被拒绝 — 请在浏览器设置中修改", - "settings.notifications.permission.state.granted": "已授权 — 系统通知将会显示", - "settings.notifications.permission.state.denied": "已拒绝 — 请在浏览器设置中修改", - "settings.notifications.permission.state.default": "尚未请求授权", - "settings.notifications.permission.hint": "浏览器为每个站点单独管理系统通知权限。一旦被拒绝,LedGrab 将无法再次请求 — 必须在浏览器中重置。点击地址栏中的站点图标(锁形)→ 站点设置 → 通知 → 允许,然后刷新页面。", - "settings.notifications.test_button": "发送测试通知", - "settings.notifications.saved": "通知偏好已保存", - "settings.notifications.save_error": "保存通知偏好失败", - "settings.notifications.channel.none.label": "关闭", - "settings.notifications.channel.none.desc": "屏蔽此事件", - "settings.notifications.channel.snack.label": "内置", - "settings.notifications.channel.snack.desc": "页面底部的弹出提示", - "settings.notifications.channel.os.label": "系统", - "settings.notifications.channel.os.desc": "系统通知(浏览器在后台时也能收到)", - "settings.notifications.channel.both.label": "两者", - "settings.notifications.channel.both.desc": "弹出提示与系统通知同时显示", - "settings.notifications.permission.pill.granted": "已授权", - "settings.notifications.permission.pill.denied": "已阻止", - "settings.rail.group.workspace": "工作区", - "settings.rail.group.system": "系统", - "settings.save_bar.unsaved": "字段未保存的更改:", - "settings.save_bar.revert": "还原", - "settings.save_bar.save": "保存", - "settings.section.api_keys": "身份与 API", - "settings.section.server": "服务器", - "settings.section.lifecycle": "生命周期", - "settings.section.destructive": "危险操作", - "settings.section.manual": "手动", - "settings.section.notif_channels": "通道", - "settings.section.notif_discovery": "发现", - "settings.section.notif_permission": "系统权限", - "settings.api_keys.read_only": "只读", - "settings.api_keys.meta.one": "个密钥", - "settings.api_keys.meta.many": "个密钥", - "settings.logs.sub": "服务器日志的实时流,可按级别过滤。在全屏覆盖层中打开。", - "settings.restart.sub": "重启 LedGrab 进程。捕获和已连接设备将暂停约 3 秒。", - "settings.restart.button": "重启", - "settings.notif_matrix.col.event": "事件", - "settings.notif_matrix.event_count": "4 个事件", - "settings.auto_backup.pill.running": "运行中", - "update.pill.available": "可用更新", - "update.pill.error": "错误", - "update.pill.updated": "已是最新", - "notifications.unknown_device": "未知设备", - "notifications.device_online.title": "设备已上线", - "notifications.device_online.body": "{device} 已重新上线", - "notifications.device_offline.title": "设备离线", - "notifications.device_offline.body": "{device} 没有响应", - "notifications.device_discovered.title": "发现新设备", - "notifications.device_discovered.body": "{device} 出现在网络中", - "notifications.device_lost.title": "设备消失", - "notifications.device_lost.body": "{device} 已消失", - "notifications.bulk.title": "多个设备状态变化", - "notifications.bulk.body": "同时发生 {count} 个设备事件", - "notifications.test.title": "LedGrab 测试通知", - "notifications.test.body": "通知功能已正常工作。", - "update.status_label": "更新状态", - "update.current_version": "当前版本:", - "update.badge_tooltip": "有新版本可用 — 点击查看详情", - "update.available": "版本 {version} 可用", - "update.up_to_date": "已是最新版本", - "update.prerelease": "预发布", - "update.view_release": "查看发布", - "update.dismiss": "忽略", - "update.check_now": "检查更新", - "update.check_error": "检查更新失败", - "update.last_check": "上次检查", - "update.never": "从未", - "update.release_notes": "发布说明", - "update.view_release_notes": "查看发布说明", - "update.release_notes_hint": "当前可用版本的更新内容 — 应用前请先查看变更日志。", - "update.release_notes_open": "打开", - "update.assets.title": "下载", - "update.assets.desc.windows_installer": "Windows 安装程序 — 开始菜单快捷方式、可选自启动、卸载器", - "update.assets.desc.windows_portable": "Windows 便携版 — 解压后运行 LedGrab.bat", - "update.assets.desc.windows_msi": "Windows MSI 安装程序", - "update.assets.desc.windows_exe": "Windows 可执行文件", - "update.assets.desc.linux_tarball": "Linux 归档 — 解压后运行 ./run.sh", - "update.assets.desc.linux_appimage": "Linux 便携版 — 单文件可执行", - "update.assets.desc.linux_deb": "Debian / Ubuntu 软件包", - "update.assets.desc.linux_rpm": "Fedora / RHEL 软件包", - "update.assets.desc.linux_sandbox": "Linux 沙盒软件包", - "update.assets.desc.macos_dmg": "macOS 磁盘镜像 — 拖入「应用程序」", - "update.assets.desc.macos_installer": "macOS 安装包", - "update.assets.desc.android": "Android — 在 Android 7.0+ 上侧载", - "update.assets.desc.android_bundle": "Android App Bundle(Play Store)", - "update.assets.desc.ios": "iOS 应用", - "update.assets.desc.zip_archive": "ZIP 归档", - "update.assets.desc.tarball": "Tar 归档", - "update.assets.desc.archive": "压缩归档", - "update.auto_check_label": "自动检查设置", - "update.auto_check_hint": "在后台定期检查新版本。", - "update.enable": "启用自动检查", - "update.interval_label": "检查间隔", - "update.channel_label": "频道", - "update.channel.stable": "稳定版", - "update.channel.stable_desc": "仅稳定版本", - "update.channel.prerelease": "预发布", - "update.channel.prerelease_desc": "包括 alpha、beta 和 RC 版本", - "update.save_settings": "保存设置", - "update.settings_saved": "更新设置已保存", - "update.settings_save_error": "保存更新设置失败", - "update.apply_now": "立即更新", - "update.apply_confirm": "下载并安装版本 {version}?服务器将自动重启。", - "update.apply_error": "更新失败", - "update.applying": "正在应用更新…", - "update.downloading": "正在下载…", - "update.install_type_label": "安装类型:", - "update.install_type.installer": "Windows 安装程序", - "update.install_type.portable": "便携版", - "update.install_type.docker": "Docker", - "update.install_type.dev": "开发环境", - "color_strip.notification.search_apps": "搜索通知应用…", - "asset.group.title": "资源", - "asset.upload": "上传资源", - "asset.edit": "编辑资源", - "asset.name": "名称:", - "asset.name.hint": "资源的显示名称。", - "asset.description": "描述:", - "asset.description.hint": "资源的可选描述。", - "asset.file": "文件:", - "asset.file.hint": "选择要上传的文件(声音、图片、视频或其他)。", - "asset.drop_or_browse": "拖放文件到此处或点击浏览", - "asset.uploaded": "资源已上传", - "asset.updated": "资源已更新", - "asset.deleted": "资源已删除", - "asset.confirm_delete": "删除此资源?", - "asset.error.name_required": "名称为必填项", - "asset.error.no_file": "请选择要上传的文件", - "asset.error.delete_failed": "删除资源失败", - "asset.error.play_failed": "播放声音失败", - "asset.error.download_failed": "下载资源失败", - "asset.play": "播放", - "asset.download": "下载", - "asset.prebuilt": "内置", - "asset.prebuilt_restored": "已恢复 {count} 个内置资源", - "asset.prebuilt_none_to_restore": "所有内置资源均已可用", - "asset.restore_prebuilt": "恢复内置声音", - "asset.type.sound": "声音", - "asset.type.image": "图片", - "asset.type.video": "视频", - "asset.type.other": "其他", - "streams.group.assets": "资源", - "section.empty.assets": "暂无资源。点击 + 上传一个。", - "donation.message": "LedGrab 是免费开源软件。如果它对您有帮助,请考虑支持开发。", - "donation.support": "支持项目", - "donation.view_source": "查看源代码", - "donation.later": "稍后提醒", - "donation.dismiss": "不再显示", - "donation.about_title": "关于 LedGrab", - "donation.about_opensource": "LedGrab 是开源软件,可免费使用和修改。", - "donation.about_donate": "支持开发", - "donation.about_license": "MIT 许可证", - "donation.about_author": "作者:", - "streams.group.game": "游戏集成", - "tree.group.game": "游戏", - "game_integration.section_title": "游戏集成", - "section.empty.game_integrations": "暂无游戏集成。点击 + 创建。", - "game_integration.add": "添加游戏集成", - "game_integration.edit": "编辑游戏集成", - "game_integration.created": "游戏集成已创建", - "game_integration.updated": "游戏集成已更新", - "game_integration.deleted": "游戏集成已删除", - "game_integration.confirm_delete": "删除此游戏集成?", - "game_integration.error.name_required": "名称不能为空", - "game_integration.error.save_failed": "保存游戏集成失败", - "game_integration.error.delete_failed": "删除游戏集成失败", - "game_integration.error.save_first": "请先保存集成以测试连接", - "game_integration.name": "名称:", - "game_integration.name.hint": "为此游戏集成提供一个描述性名称", - "game_integration.description": "描述:", - "game_integration.description.hint": "可选描述此集成的用途", - "game_integration.enabled": "启用", - "game_integration.adapter_type": "游戏/适配器:", - "game_integration.adapter_type.hint": "选择此集成的游戏或适配器类型", - "game_integration.adapter_config": "适配器配置", - "game_integration.no_config": "此适配器无需配置。", - "game_integration.setup_instructions": "设置说明", - "game_integration.setup_instructions.hint": "按照以下步骤配置您的游戏向此集成发送数据", - "game_integration.event_mappings": "事件映射", - "game_integration.event_mappings.hint": "将游戏事件映射到 LED 效果。每种事件类型可触发不同的视觉效果。", - "game_integration.mapping.add": "+ 添加映射", - "game_integration.mapping.event_type": "事件", - "game_integration.mapping.effect_type": "效果", - "game_integration.mapping.color": "颜色", - "game_integration.mapping.duration": "持续时间 (毫秒)", - "game_integration.mapping.intensity": "强度", - "game_integration.mapping.priority": "优先级", - "game_integration.mapping.select_preset": "加载预设...", - "game_integration.preset.select": "加载预设...", - "game_integration.preset.fps_combat": "FPS 战斗", - "game_integration.preset.moba_health": "MOBA 生命值", - "game_integration.adapter": "适配器", - "game_integration.status": "状态", - "game_integration.status.active": "活跃", - "game_integration.status.inactive": "未激活", - "game_integration.mappings": "映射", - "game_integration.events.title": "实时事件", - "game_integration.events.waiting": "等待事件...", - "game_integration.events.monitor": "事件监控", - "game_integration.test.button": "测试连接", - "game_integration.test.waiting": "等待游戏事件...", - "game_integration.test.success": "连接成功!已收到事件。", - "game_integration.test.error": "连接错误", - "game_integration.test.timeout": "在超时期间内未收到事件。", - "game_integration.auto_setup": "自动配置", - "game_integration.auto_setup.success": "配置文件写入成功", - "game_integration.auto_setup.failed": "自动配置失败", - "game_integration.auto_setup.not_supported": "此适配器不支持自动配置", - "game_integration.auto_setup.game_not_found": "未找到游戏安装", - "game_integration.auto_setup.token_generated": "授权令牌已自动生成", - "game_integration.auto_setup.save_first": "请先保存集成,然后再运行自动配置", - "color_strip.type.game_event": "游戏事件", - "color_strip.type.game_event.desc": "由游戏事件触发的LED效果", - "color_strip.game_event.integration": "游戏集成:", - "color_strip.game_event.integration.hint": "选择为此源提供事件的游戏集成。", - "color_strip.game_event.idle_color": "空闲颜色:", - "color_strip.game_event.idle_color.hint": "没有活动游戏事件时的LED颜色。", - "color_strip.game_event.event_mappings": "事件映射:", - "color_strip.game_event.event_mappings.hint": "为此源覆盖或添加事件到效果的映射。这些补充集成级别的映射。", - "color_strip.game_event.error.no_integration": "请选择游戏集成。", - "color_strip.type.math_wave": "数学波", - "color_strip.type.math_wave.desc": "使用渐变色映射的数学波形生成器", - "color_strip.math_wave.gradient": "颜色渐变:", - "color_strip.math_wave.gradient.hint": "用于着色波形输出的渐变。波形值映射到此渐变的位置。", - "color_strip.math_wave.speed": "速度:", - "color_strip.math_wave.speed.hint": "动画速度倍数。较高的值使波移动更快。", - "color_strip.math_wave.waves": "波形层:", - "color_strip.math_wave.waves.hint": "添加多个组合在一起的波形层。每个波形有自己的波形、频率、振幅、相位和偏移。", - "color_strip.math_wave.add_wave": "+ 添加波形", - "color_strip.math_wave.waveform": "波形", - "color_strip.math_wave.waveform.sine": "正弦波", - "color_strip.math_wave.waveform.triangle": "三角波", - "color_strip.math_wave.waveform.sawtooth": "锯齿波", - "color_strip.math_wave.waveform.square": "方波", - "color_strip.math_wave.frequency": "频率", - "color_strip.math_wave.amplitude": "振幅", - "color_strip.math_wave.phase": "相位", - "color_strip.math_wave.offset": "偏移", - "color_strip.math_wave.error.no_waves": "请至少添加一个波形层。", - "value_source.type.game_event": "游戏事件", - "value_source.type.game_event.desc": "游戏指标(生命值、弹药、法力)作为0-1值", - "value_source.game_event.integration": "游戏集成:", - "value_source.game_event.integration.hint": "选择为此值源提供事件的游戏集成。", - "value_source.game_event.event_type": "事件类型:", - "value_source.game_event.event_type.hint": "要跟踪的持续游戏事件(生命值、法力、弹药等)。", - "value_source.game_event.min_game_value": "最小游戏值:", - "value_source.game_event.min_game_value.hint": "映射到输出0.0的原始游戏值。", - "value_source.game_event.max_game_value": "最大游戏值:", - "value_source.game_event.max_game_value.hint": "映射到输出1.0的原始游戏值。", - "value_source.game_event.smoothing": "平滑:", - "value_source.game_event.smoothing.hint": "EMA平滑系数。0 = 即时,越高越平滑。", - "value_source.game_event.default_value": "默认值:", - "value_source.game_event.default_value.hint": "在超时时间内未收到事件时的输出值。", - "value_source.game_event.timeout": "超时(秒):", - "value_source.game_event.timeout.hint": "恢复到默认值前的静默秒数。", - "audio_processing.title": "音频处理模板", - "audio_processing.add": "添加音频处理模板", - "audio_processing.edit": "编辑音频处理模板", - "audio_processing.name": "模板名称:", - "audio_processing.name.hint": "此音频处理模板的描述性名称", - "audio_processing.name_placeholder": "我的音频处理模板", - "audio_processing.description_label": "描述(可选):", - "audio_processing.description.hint": "描述此模板的用途", - "audio_processing.description_placeholder": "描述此模板...", - "audio_processing.created": "音频处理模板已创建", - "audio_processing.updated": "音频处理模板已更新", - "audio_processing.deleted": "音频处理模板已删除", - "audio_processing.delete.confirm": "确定要删除此音频处理模板吗?", - "audio_processing.error.required": "请填写所有必填字段", - "audio_processing.error.load": "加载音频处理模板时出错", - "audio_processing.error.delete": "删除音频处理模板时出错", - "audio_processing.error.clone_failed": "克隆音频处理模板失败", - "audio_processing.error.save_failed": "保存音频处理模板失败", - "audio_processing.filter_count": "过滤器数量", - "audio_processing.filters_label": "个过滤器", - "streams.group.audio_processing": "音频处理", - "section.empty.audio_processing_templates": "暂无音频处理模板。点击 + 创建一个。", - "audio_source.group.capture": "音频捕获", - "audio_source.group.processed": "已处理的源", - "pairing.title": "配对设备", - "pairing.instructions.default": "请按住设备上的配对按钮,然后点击「开始」。设备通常会打开 30 秒的配对窗口——请准备好快速操作。", - "pairing.start": "开始配对", - "pairing.pairing": "配对中…", - "pairing.cancel": "取消", - "pairing.retry": "重试", - "pairing.close": "关闭", - "pairing.success": "配对成功", - "pairing.not_ready": "设备未响应。请按下设备上的配对按钮后重试。", - "pairing.failed": "配对失败:{detail}", - "pairing.failed_prefix": "配对失败:", - "streams.group.http": "HTTP", - "http_endpoint.group.title": "HTTP 端点", - "http_endpoint.add": "添加 HTTP 端点", - "http_endpoint.edit": "编辑 HTTP 端点", - "http_endpoint.section.request": "请求", - "http_endpoint.section.headers": "请求头", - "http_endpoint.name": "名称:", - "http_endpoint.name.placeholder": "Plex 正在播放", - "http_endpoint.name.hint": "此端点的描述性名称", - "http_endpoint.url": "URL:", - "http_endpoint.url.hint": "要轮询的完整 http(s) URL,允许使用本地地址。", - "http_endpoint.method": "方法:", - "http_endpoint.method.get.desc": "获取响应体。", - "http_endpoint.method.head.desc": "仅返回状态码,不返回响应体。适合健康检查。", - "http_endpoint.auth_token": "认证令牌(可选):", - "http_endpoint.auth_token.hint": "作为 'Authorization: Bearer ' 发送。在请求头中添加自定义 Authorization 可覆盖。", - "http_endpoint.auth_token.edit_hint": "留空以保留当前令牌", - "http_endpoint.auth_token.reveal": "显示 / 隐藏令牌", - "http_endpoint.auth.set": "认证", - "http_endpoint.timeout": "超时(秒):", - "http_endpoint.timeout.hint": "单次请求的最长等待秒数。", - "http_endpoint.test": "测试请求", - "http_endpoint.test.pending": "测试中…", - "http_endpoint.test.success": "成功", - "http_endpoint.test.failed": "失败", - "http_endpoint.test.body.json": "JSON 主体", - "http_endpoint.test.body.text": "响应主体", - "http_endpoint.headers": "自定义请求头:", - "http_endpoint.headers.hint": "可选的请求头(如 X-API-Key、Accept)。", - "http_endpoint.headers.add": "添加请求头", - "http_endpoint.headers.empty": "没有自定义请求头 — 将发送默认值。", - "http_endpoint.headers.name_placeholder": "请求头名称", - "http_endpoint.headers.value_placeholder": "值", - "http_endpoint.headers.count": "{n} 个请求头", - "http_endpoint.description": "描述(可选):", - "http_endpoint.created": "已创建 HTTP 端点", - "http_endpoint.updated": "已更新 HTTP 端点", - "http_endpoint.deleted": "已删除 HTTP 端点", - "http_endpoint.delete.confirm": "删除此 HTTP 端点?引用它的值源需要重新指向其他端点。", - "http_endpoint.error.name_required": "需要名称", - "http_endpoint.error.url_required": "需要 URL", - "http_endpoint.error.timeout_invalid": "超时必须为正数", - "http_endpoint.error.load": "加载 HTTP 端点失败", - "section.empty.http_endpoints": "暂无 HTTP 端点。点击 + 添加一个。", - "device.icon.entity.http_endpoint": "HTTP 端点", - "value_source.type.http": "HTTP 轮询", - "value_source.type.http.desc": "按固定间隔轮询 HTTP 端点,并将提取的值映射到 0–1。", - "value_source.http.endpoint": "HTTP 端点:", - "value_source.http.endpoint.hint": "从 HTTP 集成选项卡中选择已保存的端点。", - "value_source.http.json_path": "JSON 路径:", - "value_source.http.json_path.hint": "为空则使用原始响应体。使用点号/索引路径,例如 MediaContainer.Metadata[0].title", - "value_source.http.interval": "间隔(秒):", - "value_source.http.interval.hint": "轮询间隔(秒)。多个值源可以以不同间隔共享同一端点。", - "value_source.http.min_value": "最小值:", - "value_source.http.min_value.hint": "映射到输出 0.0 的原始值(用于归一化)。", - "value_source.http.max_value": "最大值:", - "value_source.http.max_value.hint": "映射到输出 1.0 的原始值(用于归一化)。", - "value_source.http.modulator.summary": "调制映射(可选)", - "value_source.http.modulator.hint": "仅当此源用于驱动亮度或颜色时使用。自动化规则会直接读取提取的原始值,并忽略这些设置。", - "value_source.http.endpoint_required": "需要 HTTP 端点", - "value_source.http.interval_invalid": "间隔至少为 1 秒", - "value_source.type.template": "Jinja 模板", - "value_source.type.template.desc": "使用沙盒化的 Jinja 表达式组合已绑定的输入,计算出 0-1 的值。", - "value_source.template.expression": "表达式:", - "value_source.template.expression.hint": "返回数字的沙盒化 Jinja 表达式。已绑定的输入可按名称使用;使用 raw[name] 获取未归一化的值。", - "value_source.template.expression.placeholder": "clamp((temp - 18) / 10, 0, 1)", - "value_source.template.inputs": "输入:", - "value_source.template.inputs.hint": "将浮点值源绑定到你在表达式中引用的变量名。", - "value_source.template.inputs.empty": "暂无输入。点击 + 绑定一个值源。", - "value_source.template.add_input": "+ 添加输入", - "value_source.template.input_name": "变量名", - "value_source.template.input_count": "个输入", - "value_source.template.input_count_one": "个输入", - "value_source.template.default_value": "默认值:", - "value_source.template.default_value.hint": "当表达式无法求值(例如缺少输入)时使用的输出值。", - "value_source.template.eval_interval": "求值间隔(秒):", - "value_source.template.eval_interval.hint": "重新求值表达式的频率。0 = 每次轮询(随输入更新一样快地重新求值)。", - "value_source.template.valid": "表达式有效", - "value_source.template.hints.title": "表达式帮助", - "value_source.template.hints.inputs_title": "已绑定的输入", - "value_source.template.hints.no_inputs": "尚未绑定输入", - "value_source.template.hints.globals_title": "全局函数", - "value_source.template.hints.globals": "min(a, b), max(a, b), abs(x), round(x), clamp(x, lo, hi)", - "value_source.template.hints.raw_title": "原始值", - "value_source.template.hints.raw": "raw[name] 给出某个输入的未归一化值(如果有)。", - "value_source.template.hints.examples_title": "示例", - "value_source.template.hints.time": "提示:要实现一天中的时间逻辑,请将“自适应(时间)”或“日光周期”源绑定为输入。", - "value_source.template.error.invalid_expr": "表达式无效", - "value_source.template.error.cycle": "该表达式会造成依赖循环", - "value_source.template.error.missing_input": "每个输入都需要一个变量名", - "value_source.template.error.invalid_name": "变量名无效", - "value_source.template.error.reserved_name": "保留名称不能用作输入", - "value_source.template.error.duplicate_name": "输入名称重复", - "value_source.template.error.unbound": "表达式引用了未绑定的变量", - "automations.rule.http_poll": "HTTP 轮询", - "automations.rule.http_poll.desc": "当 HTTP 值源的最新提取值匹配时激活。", - "automations.rule.http_poll.hint": "将最新的提取值与您的输入进行比较。提取的内容(原始响应体或 JSON 路径)由值源决定。", - "automations.rule.http_poll.value_source": "HTTP 值源", - "automations.rule.http_poll.operator": "运算符", - "automations.rule.http_poll.value": "值", - "automations.rule.http_poll.value.placeholder": "playing", - "automations.rule.http_poll.no_source": "(无来源)", - "automations.rule.http_poll.raw_body": "原始响应体", - "automations.rule.http_poll.operator.equals": "等于", - "automations.rule.http_poll.operator.equals.desc": "精确字符串匹配。", - "automations.rule.http_poll.operator.not_equals": "不等于", - "automations.rule.http_poll.operator.not_equals.desc": "当值不同时激活。", - "automations.rule.http_poll.operator.contains": "包含", - "automations.rule.http_poll.operator.contains.desc": "子字符串匹配。", - "automations.rule.http_poll.operator.regex": "正则", - "automations.rule.http_poll.operator.regex.desc": "JavaScript 风格的正则表达式。", - "automations.rule.http_poll.operator.gt": "大于", - "automations.rule.http_poll.operator.gt.desc": "数值比较 (>) — 需要数值输出。", - "automations.rule.http_poll.operator.lt": "小于", - "automations.rule.http_poll.operator.lt.desc": "数值比较 (<) — 需要数值输出。", - "automations.rule.http_poll.operator.exists": "存在", - "automations.rule.http_poll.operator.exists.desc": "只要成功提取出值就激活(忽略值本身)。", - "autocal.modal.title": "自动校准灯带", - "autocal.trigger.label": "自动校准", - "autocal.trigger.hint": "通过逐一扫描灯带自动检测 LED 位置", - "autocal.device.title": "选择设备", - "autocal.device.desc": "选择驱动该 LED 灯带的 WLED 设备。校准过程中灯带会短暂亮起。", - "autocal.device.label": "设备", - "autocal.error.no_device": "请选择一个设备以继续。", - "autocal.corner.title": "起始角", - "autocal.corner.desc": "灯带第 0 颗 LED(最开始的一颗)位于哪个角?", - "autocal.corner.led_index": "LED 0 位置", - "autocal.direction.title": "灯带方向 — 步骤 {step}", - "autocal.direction.desc": "从起始角开始,灯带向哪个方向延伸?", - "autocal.corners.title": "标记角点 — 剩余 {remaining} 个", - "autocal.corners.desc": "移动到下一个角点后点击标记。当前角点:{corner}", - "autocal.corners.desc_complete": "已标记全部 4 个角点!请确认后继续。", - "autocal.corners.index_label": "LED 索引", - "autocal.preview.title": "预览并保存", - "autocal.preview.desc": "确认检测到的布局,然后保存到灯带源。", - "autocal.preview.start": "起始角", - "autocal.preview.top": "顶部 LED 数", - "autocal.preview.right": "右侧 LED 数", - "autocal.preview.bottom": "底部 LED 数", - "autocal.preview.left": "左侧 LED 数", - "autocal.preview.total": "LED 总数", - "autocal.position.top_left": "左上角", - "autocal.position.top_right": "右上角", - "autocal.position.bottom_left": "左下角", - "autocal.position.bottom_right": "右下角", - "autocal.btn.cancel": "取消", - "autocal.btn.next": "下一步", - "autocal.btn.back": "返回", - "autocal.btn.step_back": "后退一步", - "autocal.btn.step_fwd": "前进一步", - "autocal.btn.mark_corner": "标记角点", - "autocal.btn.solve": "求解", - "autocal.btn.save": "保存", - "autocal.error.session_start_failed": "无法启动校准会话。", - "autocal.error.session_stop_failed": "无法停止校准会话。", - "autocal.error.position_failed": "无法移动到 LED 位置。", - "autocal.error.solve_failed": "校准求解失败。", - "autocal.error.save_failed": "保存校准数据失败。", - "autocal.error.css_required": "自动校准需要颜色灯带源(不支持纯设备目标)。", - "autocal.saved": "校准已成功保存。", - "wizard.modal.title": "设置向导", - "wizard.rerun": "重新运行设置向导", - "wizard.skip": "跳过", - "wizard.start": "开始设置", - "wizard.step.welcome": "欢迎", - "wizard.step.device": "设备", - "wizard.step.display": "屏幕", - "wizard.step.scaffold": "配置", - "wizard.step.calibrate": "校准", - "wizard.step.start": "启动", - "wizard.step.done": "完成", - "wizard.welcome.title": "欢迎使用 LED Grab", - "wizard.welcome.desc": "只需几步,即可启动并运行您的 LED 灯带。", - "wizard.welcome.item1": "连接您的 LED 控制器", - "wizard.welcome.item2": "选择要采集的屏幕", - "wizard.welcome.item3": "校准灯带布局", - "wizard.welcome.item4": "启动氛围灯输出", - "wizard.device.title": "查找您的设备", - "wizard.device.desc": "扫描网络查找兼容的 LED 控制器,或手动添加。", - "wizard.device.scanning": "正在扫描网络…", - "wizard.device.discovered": "在网络中发现", - "wizard.device.none_found": "未找到设备。请尝试手动添加。", - "wizard.device.rescan": "重新扫描", - "wizard.device.existing": "已有设备", - "wizard.device.manual.title": "手动添加", - "wizard.device.manual.name": "设备名称", - "wizard.device.manual.name_placeholder": "我的 LED 灯带", - "wizard.device.manual.url": "设备地址", - "wizard.device.manual.led_count": "LED 数量", - "wizard.device.manual.add": "添加设备", - "wizard.display.title": "选择您的屏幕", - "wizard.display.desc": "选择用于采集氛围灯的显示器。", - "wizard.display.loading": "正在加载显示器…", - "wizard.display.no_displays": "未检测到显示器。请手动输入显示器序号。", - "wizard.display.manual_index": "显示器序号", - "wizard.display.primary": "主显示器", - "wizard.display.index_prefix": "显示器", - "wizard.display.confirm": "使用此屏幕", - "wizard.scaffold.title": "正在创建配置", - "wizard.scaffold.desc": "正在创建采集链:屏幕源 → 色带 → LED 输出。", - "wizard.scaffold.building": "正在创建实体…", - "wizard.scaffold.done": "配置完成!准备好进行校准。", - "wizard.calibrate.title": "校准灯带布局", - "wizard.calibrate.desc": "告诉 LedGrab 您的 LED 灯带从哪里开始,以及它如何绕屏幕布置。", - "wizard.calibrate.skip": "跳过校准", - "wizard.start.title": "正在启动输出", - "wizard.start.starting": "正在启动 LED 输出…", - "wizard.start.done": "LED 输出正在运行!", - "wizard.start.failed": "启动输出失败。您可以在「目标」选项卡中手动启动。", - "wizard.done.title": "全部完成!", - "wizard.done.desc": "您的氛围 LED 设置已激活。尽情享受灯光吧!", - "wizard.done.device": "设备", - "wizard.done.display": "屏幕", - "wizard.done.finish": "完成", - "wizard.error.no_device": "请先选择或添加一个设备。", - "wizard.error.device_create_failed": "创建设备失败。", - "wizard.error.device_name_required": "设备名称不能为空。", - "wizard.error.device_url_required": "设备地址不能为空。", - "wizard.error.scaffold_failed": "配置失败,请重试。", - "wizard.error.start_failed": "启动 LED 输出失败。" + "accent.color.updated": "强调色已更新", + "accent.custom": "自定义", + "accent.reset": "重置", + "accent.title": "主题色", + "activity_log.category.auth": "身份验证", + "activity_log.category.capture": "采集", + "activity_log.category.device": "设备", + "activity_log.category.entity": "实体", + "activity_log.category.system": "系统", + "activity_log.col.actor": "操作者", + "activity_log.col.category": "类别", + "activity_log.col.message": "消息", + "activity_log.col.severity": "严重性", + "activity_log.col.time": "时间", + "activity_log.detail.action": "操作", + "activity_log.detail.actor": "操作者", + "activity_log.detail.entity": "实体", + "activity_log.detail.id": "ID", + "activity_log.detail.metadata": "元数据", + "activity_log.detail.timestamp": "时间戳", + "activity_log.detail.title": "记录详情", + "activity_log.disabled": "活动日志记录已禁用。", + "activity_log.empty": "没有符合当前过滤条件的记录。", + "activity_log.empty_no_filters": "尚无活动记录。", + "activity_log.error": "加载活动日志失败。", + "activity_log.export": "导出", + "activity_log.export.csv": "导出 CSV", + "activity_log.export.downloading": "下载中…", + "activity_log.export.error": "导出失败。", + "activity_log.export.json": "导出 JSON", + "activity_log.filter.actor": "操作者", + "activity_log.filter.actor.placeholder": "system、API 密钥名…", + "activity_log.filter.category": "类别", + "activity_log.filter.clear": "清除过滤", + "activity_log.filter.entity_type": "实体类型", + "activity_log.filter.entity_type.placeholder": "output_target, device…", + "activity_log.filter.search": "搜索消息…", + "activity_log.filter.severity": "严重性", + "activity_log.filter.since": "从", + "activity_log.filter.title": "过滤", + "activity_log.filter.until": "至", + "activity_log.live": "实时", + "activity_log.load_more": "加载更多", + "activity_log.loading": "正在加载活动日志…", + "activity_log.n_entries": "{n} 条记录", + "activity_log.new_entries": "+{n} 条新记录", + "activity_log.preset.auth": "身份验证", + "activity_log.preset.devices": "设备", + "activity_log.preset.errors": "错误", + "activity_log.preset.today": "今天", + "activity_log.severity.error": "错误", + "activity_log.severity.info": "信息", + "activity_log.severity.warning": "警告", + "activity_log.subtitle": "LedGrab 操作审计日志", + "activity_log.title": "活动", + "activity_log.msg.auth.rejected": "身份验证失败:{reason}(客户端:{client})", + "activity_log.msg.auth.ws_connected": "WebSocket 会话已建立:'{actor}'", + "activity_log.msg.activity_log.cleared": "活动日志已由 '{actor}' 清除", + "activity_log.msg.calibration.started": "校准已启动", + "activity_log.msg.calibration.stopped": "校准已停止", + "activity_log.msg.calibration.cancelled": "校准已取消", + "activity_log.msg.playlist.started": "播放列表 '{name}' 已启动", + "activity_log.msg.playlist.stopped": "播放列表 '{name}' 已停止", + "activity_log.msg.scene.activated": "场景 '{name}' 已激活", + "activity_log.msg.device.adb_connected": "ADB 已连接到 {address}", + "activity_log.msg.device.adb_disconnected": "ADB 已断开与 {address} 的连接", + "activity_log.msg.device.discovered": "在 {address} 发现设备", + "activity_log.msg.device.lost": "在 {address} 的设备已丢失", + "activity_log.msg.device.online": "设备 '{name}' 已上线", + "activity_log.msg.device.offline": "设备 '{name}' 已离线", + "activity_log.msg.update.applied": "更新已应用", + "activity_log.msg.update.dismissed": "更新已忽略", + "activity_log.msg.settings.changed": "设置 '{key}' 已更改", + "activity_log.msg.audit_log.disabled": "活动记录已禁用", + "activity_log.msg.automation.activated": "自动化 '{name}' 已激活", + "activity_log.msg.automation.deactivated": "自动化 '{name}' 已停用", + "activity_log.msg.server.shutting_down": "服务器正在关闭", + "activity_log.msg.server.restarting": "已请求服务器重启", + "activity_log.msg.server.shutdown_requested": "已请求服务器关闭", + "activity_log.msg.backup.created": "备份已创建", + "activity_log.msg.backup.restored": "备份已还原", + "activity_log.msg.backup.deleted": "备份已删除", + "activity_log.msg.capture.started": "'{name}' 的采集已启动", + "activity_log.msg.capture.stopped": "'{name}' 的采集已停止", + "activity_log.msg.entity.created": "{type} '{name}' 已创建", + "activity_log.msg.entity.updated": "{type} '{name}' 已更新", + "activity_log.msg.entity.deleted": "{type} '{name}' 已删除", + "activity_log.entity_type.output_target": "输出目标", + "activity_log.entity_type.device": "设备", + "activity_log.entity_type.picture_source": "图像源", + "activity_log.entity_type.color_strip_source": "色带源", + "activity_log.entity_type.audio_source": "音频源", + "activity_log.entity_type.automation": "自动化", + "activity_log.entity_type.scene_preset": "场景预设", + "activity_log.entity_type.scene_playlist": "场景播放列表", + "activity_log.entity_type.sync_clock": "同步时钟", + "activity_log.entity_type.template": "模板", + "activity_log.entity_type.gradient": "渐变", + "activity_log.entity_type.cspt": "处理模板", + "activity_log.entity_type.audio_template": "音频模板", + "activity_log.entity_type.audio_processing_template": "音频处理模板", + "activity_log.entity_type.audio_processing_profile": "音频处理配置", + "activity_log.entity_type.http_endpoint": "HTTP 端点", + "activity_log.entity_type.mqtt_source": "MQTT 源", + "activity_log.entity_type.home_assistant_source": "Home Assistant 源", + "activity_log.entity_type.weather_source": "天气源", + "activity_log.entity_type.game_integration": "游戏集成", + "activity_log.entity_type.value_source": "数值源", + "activity_log.entity_type.asset": "资源", + "api.error.network": "网络错误 — 请检查连接", + "api.error.timeout": "请求超时 — 请重试", + "api_key.login": "登录", + "app.api_docs": "API 文档", + "app.connection_lost": "服务器不可达", + "app.connection_retrying": "正在尝试重新连接…", + "app.server_restarting": "服务器正在重启…", + "app.server_restarting_sub": "请稍候,服务器即将恢复。", + "app.title": "LED Grab", + "app.version": "版本:", + "appearance.bg.applied": "背景效果已应用", + "appearance.bg.aurora": "极光", + "appearance.bg.grid": "点阵", + "appearance.bg.hint": "在界面后面添加环境背景层。", + "appearance.bg.label": "背景效果", + "appearance.bg.mesh": "渐变网格", + "appearance.bg.noise": "噪声场", + "appearance.bg.none": "无", + "appearance.bg.plasma": "等离子", + "appearance.bg.rain": "数字雨", + "appearance.bg.scanlines": "扫描线", + "appearance.bg.stars": "星空", + "appearance.bg.warp": "隧道", + "appearance.preset.applied": "样式已应用", + "appearance.preset.arctic": "极地", + "appearance.preset.copper": "铜色", + "appearance.preset.default": "默认", + "appearance.preset.ember": "余烬", + "appearance.preset.midnight": "午夜", + "appearance.preset.monolith": "黑白", + "appearance.preset.neon": "霓虹", + "appearance.preset.ocean": "海洋", + "appearance.preset.sakura": "樱花", + "appearance.preset.terminal": "终端", + "appearance.preset.vapor": "蒸汽波", + "appearance.style.hint": "选择一个视觉主题 — 字体和配色方案一起应用。", + "appearance.style.label": "样式预设", + "aria.cancel": "取消", + "aria.close": "关闭", + "aria.hint": "显示提示", + "aria.next": "下一个", + "aria.previous": "上一个", + "aria.save": "保存", + "asset.confirm_delete": "删除此资源?", + "asset.deleted": "资源已删除", + "asset.description": "描述:", + "asset.description.hint": "资源的可选描述。", + "asset.download": "下载", + "asset.drop_or_browse": "拖放文件到此处或点击浏览", + "asset.edit": "编辑资源", + "asset.error.delete_failed": "删除资源失败", + "asset.error.download_failed": "下载资源失败", + "asset.error.name_required": "名称为必填项", + "asset.error.no_file": "请选择要上传的文件", + "asset.error.play_failed": "播放声音失败", + "asset.file": "文件:", + "asset.file.hint": "选择要上传的文件(声音、图片、视频或其他)。", + "asset.group.title": "资源", + "asset.name": "名称:", + "asset.name.hint": "资源的显示名称。", + "asset.play": "播放", + "asset.prebuilt": "内置", + "asset.prebuilt_none_to_restore": "所有内置资源均已可用", + "asset.prebuilt_restored": "已恢复 {count} 个内置资源", + "asset.restore_prebuilt": "恢复内置声音", + "asset.type.image": "图片", + "asset.type.other": "其他", + "asset.type.sound": "声音", + "asset.type.video": "视频", + "asset.updated": "资源已更新", + "asset.upload": "上传资源", + "asset.uploaded": "资源已上传", + "audio_processing.add": "添加音频处理模板", + "audio_processing.created": "音频处理模板已创建", + "audio_processing.delete.confirm": "确定要删除此音频处理模板吗?", + "audio_processing.deleted": "音频处理模板已删除", + "audio_processing.description.hint": "描述此模板的用途", + "audio_processing.description_label": "描述(可选):", + "audio_processing.description_placeholder": "描述此模板...", + "audio_processing.edit": "编辑音频处理模板", + "audio_processing.error.clone_failed": "克隆音频处理模板失败", + "audio_processing.error.delete": "删除音频处理模板时出错", + "audio_processing.error.load": "加载音频处理模板时出错", + "audio_processing.error.required": "请填写所有必填字段", + "audio_processing.error.save_failed": "保存音频处理模板失败", + "audio_processing.filter_count": "过滤器数量", + "audio_processing.filters_label": "个过滤器", + "audio_processing.name": "模板名称:", + "audio_processing.name.hint": "此音频处理模板的描述性名称", + "audio_processing.name_placeholder": "我的音频处理模板", + "audio_processing.title": "音频处理模板", + "audio_processing.updated": "音频处理模板已更新", + "audio_source.add": "添加音频源", + "audio_source.add.capture": "添加捕获源", + "audio_source.add.processed": "添加处理源", + "audio_source.audio_template": "音频模板:", + "audio_source.audio_template.hint": "定义此设备使用哪个引擎和设置的音频采集模板", + "audio_source.created": "音频源已创建", + "audio_source.delete.confirm": "确定要删除此音频源吗?", + "audio_source.deleted": "音频源已删除", + "audio_source.description": "描述(可选):", + "audio_source.description.hint": "关于此音频源的可选说明", + "audio_source.description.placeholder": "描述此音频源...", + "audio_source.device": "音频设备:", + "audio_source.device.hint": "音频输入源。回环设备采集系统音频输出;输入设备采集麦克风或线路输入。", + "audio_source.edit": "编辑音频源", + "audio_source.edit.capture": "编辑捕获源", + "audio_source.edit.processed": "编辑处理源", + "audio_source.error.load": "加载音频源失败", + "audio_source.error.name_required": "请输入名称", + "audio_source.group.capture": "音频捕获", + "audio_source.group.processed": "已处理的源", + "audio_source.name": "名称:", + "audio_source.name.hint": "此音频源的描述性名称", + "audio_source.name.placeholder": "系统音频", + "audio_source.parent": "输入音频源:", + "audio_source.parent.hint": "要应用处理滤波器的音频源", + "audio_source.processing_template": "处理模板:", + "audio_source.processing_template.hint": "包含要应用于输入源的滤波器的音频处理模板", + "audio_source.refresh_devices": "刷新设备", + "audio_source.test": "测试", + "audio_source.test.beat": "节拍", + "audio_source.test.connecting": "连接中...", + "audio_source.test.error": "音频测试失败", + "audio_source.test.peak": "峰值", + "audio_source.test.rms": "RMS", + "audio_source.test.title": "测试音频源", + "audio_source.title": "音频源", + "audio_source.type": "类型:", + "audio_source.type.capture": "捕获", + "audio_source.type.hint": "捕获包装物理音频设备。处理将音频处理滤波器应用于另一个源。", + "audio_source.type.processed": "已处理", + "audio_source.updated": "音频源已更新", + "audio_template.add": "添加音频模板", + "audio_template.config": "配置", + "audio_template.config.show": "显示配置", + "audio_template.created": "音频模板已创建", + "audio_template.delete.confirm": "确定要删除此音频模板吗?", + "audio_template.deleted": "音频模板已删除", + "audio_template.description.label": "描述(可选):", + "audio_template.description.placeholder": "描述此模板...", + "audio_template.edit": "编辑音频模板", + "audio_template.engine": "音频引擎:", + "audio_template.engine.hint": "选择要使用的音频采集后端。WASAPI 仅限 Windows 并支持回环。Sounddevice 支持跨平台。", + "audio_template.engine.unavailable": "不可用", + "audio_template.engine.unavailable.hint": "此引擎在您的系统上不可用", + "audio_template.error.clone_failed": "克隆音频模板失败", + "audio_template.error.delete": "删除音频模板失败", + "audio_template.error.engines": "加载音频引擎失败", + "audio_template.error.load": "加载音频模板失败", + "audio_template.error.load_failed": "加载音频模板失败", + "audio_template.error.required": "请填写所有必填项", + "audio_template.error.save_failed": "保存音频模板失败", + "audio_template.name": "模板名称:", + "audio_template.name.placeholder": "我的音频模板", + "audio_template.test": "测试", + "audio_template.test.device": "音频设备:", + "audio_template.test.device.hint": "选择测试期间要采集的音频设备", + "audio_template.test.run": "运行", + "audio_template.test.title": "测试音频模板", + "audio_template.title": "音频模板", + "audio_template.updated": "音频模板已更新", + "auth.authenticated": "● 已认证", + "auth.button.cancel": "取消", + "auth.button.login": "登录", + "auth.error.invalid": "API 密钥无效,请重试。", + "auth.error.required": "请输入 API 密钥", + "auth.hint": "API 密钥将安全存储在浏览器的本地存储中。", + "auth.label": "API 密钥:", + "auth.login": "登录", + "auth.logout": "退出", + "auth.logout.confirm": "确定要退出登录吗?", + "auth.logout.success": "已成功退出", + "auth.message": "请输入 API 密钥以进行身份验证并访问 LED Grab。", + "auth.placeholder": "输入您的 API 密钥...", + "auth.please_login": "请先登录", + "auth.prompt_enter": "Enter your API key:", + "auth.prompt_update": "Current API key is set. Enter new key to update or leave blank to remove:", + "auth.session_expired": "会话已过期或 API 密钥无效,请重新登录。", + "auth.success": "登录成功!", + "auth.title": "登录 LED Grab", + "auth.toggle_password": "切换密码可见性", + "autocal.btn.back": "返回", + "autocal.btn.cancel": "取消", + "autocal.btn.mark_corner": "标记角点", + "autocal.btn.next": "下一步", + "autocal.btn.save": "保存", + "autocal.btn.solve": "求解", + "autocal.btn.step_back": "后退一步", + "autocal.btn.step_fwd": "前进一步", + "autocal.corner.desc": "灯带第 0 颗 LED(最开始的一颗)位于哪个角?", + "autocal.corner.led_index": "LED 0 位置", + "autocal.corner.title": "起始角", + "autocal.corners.desc": "移动到下一个角点后点击标记。当前角点:{corner}", + "autocal.corners.desc_complete": "已标记全部 4 个角点!请确认后继续。", + "autocal.corners.index_label": "LED 索引", + "autocal.corners.title": "标记角点 — 剩余 {remaining} 个", + "autocal.device.desc": "选择驱动该 LED 灯带的 WLED 设备。校准过程中灯带会短暂亮起。", + "autocal.device.label": "设备", + "autocal.device.title": "选择设备", + "autocal.direction.desc": "从起始角开始,灯带向哪个方向延伸?", + "autocal.direction.title": "灯带方向 — 步骤 {step}", + "autocal.error.css_required": "自动校准需要颜色灯带源(不支持纯设备目标)。", + "autocal.error.no_device": "请选择一个设备以继续。", + "autocal.error.position_failed": "无法移动到 LED 位置。", + "autocal.error.save_failed": "保存校准数据失败。", + "autocal.error.session_start_failed": "无法启动校准会话。", + "autocal.error.session_stop_failed": "无法停止校准会话。", + "autocal.error.solve_failed": "校准求解失败。", + "autocal.modal.title": "自动校准灯带", + "autocal.position.bottom_left": "左下角", + "autocal.position.bottom_right": "右下角", + "autocal.position.top_left": "左上角", + "autocal.position.top_right": "右上角", + "autocal.preview.bottom": "底部 LED 数", + "autocal.preview.desc": "确认检测到的布局,然后保存到灯带源。", + "autocal.preview.left": "左侧 LED 数", + "autocal.preview.right": "右侧 LED 数", + "autocal.preview.start": "起始角", + "autocal.preview.title": "预览并保存", + "autocal.preview.top": "顶部 LED 数", + "autocal.preview.total": "LED 总数", + "autocal.saved": "校准已成功保存。", + "autocal.trigger.hint": "通过逐一扫描灯带自动检测 LED 位置", + "autocal.trigger.label": "自动校准", + "automation.disabled": "自动化已禁用", + "automation.enabled": "自动化已启用", + "automations.action.disable": "禁用", + "automations.add": "添加自动化", + "automations.created": "自动化已创建", + "automations.deactivation_mode": "停用方式:", + "automations.deactivation_mode.fallback_scene": "备用", + "automations.deactivation_mode.fallback_scene.desc": "激活备用场景", + "automations.deactivation_mode.hint": "条件不再满足时的行为", + "automations.deactivation_mode.none": "无", + "automations.deactivation_mode.none.desc": "保持当前状态", + "automations.deactivation_mode.revert": "恢复", + "automations.deactivation_mode.revert.desc": "恢复到之前的状态", + "automations.deactivation_scene": "备用场景:", + "automations.deactivation_scene.hint": "自动化停用时激活的场景", + "automations.delete.confirm": "删除自动化 \"{name}\"?", + "automations.deleted": "自动化已删除", + "automations.edit": "编辑自动化", + "automations.empty": "尚未配置自动化。创建一个以自动激活场景。", + "automations.enabled": "启用:", + "automations.enabled.hint": "禁用的自动化即使满足条件也不会激活", + "automations.error.clone_failed": "克隆自动化失败", + "automations.error.delete_failed": "删除自动化失败", + "automations.error.load_failed": "加载自动化失败", + "automations.error.name_required": "名称为必填项", + "automations.error.save_failed": "保存自动化失败", + "automations.error.toggle_failed": "切换自动化失败", + "automations.last_activated": "上次激活", + "automations.logic.all": "全部", + "automations.logic.and": " 与 ", + "automations.logic.any": "任一", + "automations.logic.or": " 或 ", + "automations.name": "名称:", + "automations.name.hint": "此自动化的描述性名称", + "automations.name.placeholder": "我的自动化", + "automations.rule.application": "应用程序", + "automations.rule.application.apps": "应用程序:", + "automations.rule.application.apps.hint": "进程名,每行一个(例如 firefox.exe)", + "automations.rule.application.apps.hint_android": "包名,每行一个(例如 com.netflix.mediaclient)", + "automations.rule.application.browse": "浏览", + "automations.rule.application.desc": "应用运行/聚焦", + "automations.rule.application.match_type": "匹配类型:", + "automations.rule.application.match_type.fullscreen": "全屏", + "automations.rule.application.match_type.fullscreen.desc": "任意全屏应用", + "automations.rule.application.match_type.hint": "如何检测应用程序", + "automations.rule.application.match_type.running": "运行中", + "automations.rule.application.match_type.running.desc": "进程活跃", + "automations.rule.application.match_type.topmost": "最前", + "automations.rule.application.match_type.topmost.desc": "前台窗口", + "automations.rule.application.match_type.topmost_fullscreen": "最前 + 全屏", + "automations.rule.application.match_type.topmost_fullscreen.desc": "前台 + 全屏", + "automations.rule.application.no_apps": "未找到应用", + "automations.rule.application.no_processes": "未找到进程", + "automations.rule.application.search": "筛选进程...", + "automations.rule.application.search_apps": "筛选应用…", + "automations.rule.application.usage_access_required": "需要使用情况访问权限。在您的 LedGrab 电视上打开应用并点按「授予使用情况访问权限」。", + "automations.rule.display_state": "显示器状态", + "automations.rule.display_state.desc": "显示器开/关", + "automations.rule.display_state.off": "关闭(休眠)", + "automations.rule.display_state.on": "开启", + "automations.rule.display_state.state": "显示器状态:", + "automations.rule.http_poll": "HTTP 轮询", + "automations.rule.http_poll.desc": "当 HTTP 值源的最新提取值匹配时激活。", + "automations.rule.http_poll.hint": "将最新的提取值与您的输入进行比较。提取的内容(原始响应体或 JSON 路径)由值源决定。", + "automations.rule.http_poll.no_source": "(无来源)", + "automations.rule.http_poll.operator": "运算符", + "automations.rule.http_poll.operator.contains": "包含", + "automations.rule.http_poll.operator.contains.desc": "子字符串匹配。", + "automations.rule.http_poll.operator.equals": "等于", + "automations.rule.http_poll.operator.equals.desc": "精确字符串匹配。", + "automations.rule.http_poll.operator.exists": "存在", + "automations.rule.http_poll.operator.exists.desc": "只要成功提取出值就激活(忽略值本身)。", + "automations.rule.http_poll.operator.gt": "大于", + "automations.rule.http_poll.operator.gt.desc": "数值比较 (\u003e) — 需要数值输出。", + "automations.rule.http_poll.operator.lt": "小于", + "automations.rule.http_poll.operator.lt.desc": "数值比较 (\u003c) — 需要数值输出。", + "automations.rule.http_poll.operator.not_equals": "不等于", + "automations.rule.http_poll.operator.not_equals.desc": "当值不同时激活。", + "automations.rule.http_poll.operator.regex": "正则", + "automations.rule.http_poll.operator.regex.desc": "JavaScript 风格的正则表达式。", + "automations.rule.http_poll.raw_body": "原始响应体", + "automations.rule.http_poll.value": "值", + "automations.rule.http_poll.value.placeholder": "playing", + "automations.rule.http_poll.value_source": "HTTP 值源", + "automations.rule.mqtt": "MQTT", + "automations.rule.mqtt.desc": "MQTT 消息", + "automations.rule.mqtt.hint": "当 MQTT 主题收到匹配的消息时激活", + "automations.rule.mqtt.match_mode": "匹配模式:", + "automations.rule.mqtt.match_mode.contains": "包含", + "automations.rule.mqtt.match_mode.exact": "精确匹配", + "automations.rule.mqtt.match_mode.regex": "正则表达式", + "automations.rule.mqtt.payload": "消息内容:", + "automations.rule.mqtt.topic": "主题:", + "automations.rule.startup": "启动", + "automations.rule.startup.desc": "服务器启动时", + "automations.rule.startup.hint": "服务器启动时激活,启用期间保持活动。", + "automations.rule.system_idle": "系统空闲", + "automations.rule.system_idle.desc": "空闲/活跃", + "automations.rule.system_idle.idle_minutes": "空闲超时(分钟):", + "automations.rule.system_idle.mode": "触发模式:", + "automations.rule.system_idle.when_active": "活跃时", + "automations.rule.system_idle.when_active.desc": "当用户正在使用系统时触发", + "automations.rule.system_idle.when_idle": "空闲时", + "automations.rule.system_idle.when_idle.desc": "当用户空闲时间超过超时阈值后触发", + "automations.rule.time_of_day": "时段", + "automations.rule.time_of_day.days": "生效日期", + "automations.rule.time_of_day.days_hint": "全部不选表示每天生效。跨夜时段归属于其开始的那一天。", + "automations.rule.time_of_day.desc": "时间范围", + "automations.rule.time_of_day.end_time": "结束时间:", + "automations.rule.time_of_day.overnight_hint": "跨夜时段(如 22:00–06:00),请将开始时间设为晚于结束时间。", + "automations.rule.time_of_day.start_time": "开始时间:", + "automations.rule.time_of_day.timezone": "时区", + "automations.rule.time_of_day.timezone.placeholder": "服务器本地时间(如 Europe/Berlin)", + "automations.rule.webhook": "Webhook", + "automations.rule.webhook.copied": "已复制!", + "automations.rule.webhook.copy": "复制", + "automations.rule.webhook.desc": "HTTP 回调", + "automations.rule.webhook.hint": "通过外部服务的 HTTP 请求激活(Home Assistant、IFTTT、curl 等)", + "automations.rule.webhook.save_first": "请先保存自动化以生成 Webhook URL", + "automations.rule.webhook.url": "Webhook URL:", + "automations.rule_logic": "条件逻辑:", + "automations.rule_logic.and": "全部条件(与)", + "automations.rule_logic.and.desc": "全部匹配时才触发", + "automations.rule_logic.hint": "多个条件的组合方式:任一(或)或 全部(与)", + "automations.rule_logic.or": "任一条件(或)", + "automations.rule_logic.or.desc": "任一条件匹配时触发", + "automations.rules": "条件:", + "automations.rules.add": "添加条件", + "automations.rules.empty": "无条件 — 启用后自动化始终处于活动状态", + "automations.rules.hint": "决定此自动化何时激活的规则", + "automations.scene": "场景:", + "automations.scene.hint": "条件满足时激活的场景预设", + "automations.scene.none_available": "没有可用的场景", + "automations.scene.none_selected": "无(无场景)", + "automations.scene.search_placeholder": "搜索场景...", + "automations.section.action": "动作", + "automations.section.deactivation": "停用", + "automations.section.triggers": "触发器", + "automations.status.active": "活动", + "automations.status.disabled": "已禁用", + "automations.status.inactive": "非活动", + "automations.title": "自动化", + "automations.updated": "自动化已更新", + "bg.anim.toggle": "切换动态背景", + "bindable.none": "无(静态值)", + "bindable.toggle": "切换值源绑定", + "bulk.cancel": "取消", + "bulk.confirm_delete.one": "删除 {count} 项?", + "bulk.confirm_delete.other": "删除 {count} 项?", + "bulk.delete": "删除", + "bulk.deselect_all": "取消全选", + "bulk.disable": "禁用", + "bulk.enable": "启用", + "bulk.processing": "处理中…", + "bulk.select": "选择", + "bulk.select_all": "全选", + "bulk.selected_count.one": "已选 {count} 项", + "bulk.selected_count.other": "已选 {count} 项", + "bulk.start": "启动", + "bulk.stop": "停止", + "calibration.advanced.border_width": "深度(像素):", + "calibration.advanced.border_width.hint": "从边缘向内采样多少像素。较大的值会捕获更多屏幕内部区域。", + "calibration.advanced.canvas_hint": "拖动显示器重新排列。点击边缘选择线段。滚动缩放,拖动空白区域平移。", + "calibration.advanced.edge": "边缘:", + "calibration.advanced.edge.hint": "从屏幕哪条边缘采样像素", + "calibration.advanced.led_count": "LED数:", + "calibration.advanced.led_count.hint": "映射到此线段的LED数量", + "calibration.advanced.line_properties": "线段属性", + "calibration.advanced.lines_title": "线段", + "calibration.advanced.no_lines_warning": "请至少添加一条线段", + "calibration.advanced.picture_source": "来源:", + "calibration.advanced.picture_source.hint": "此线段采样的图片来源(显示器)", + "calibration.advanced.reset_view": "重置视图", + "calibration.advanced.reverse": "反转", + "calibration.advanced.span_end": "结束位置:", + "calibration.advanced.span_end.hint": "沿边缘结束采样的位置(0 = 起点,1 = 终点)。与起始位置一起定义活动区域。", + "calibration.advanced.span_start": "起始位置:", + "calibration.advanced.span_start.hint": "沿边缘开始采样的位置(0 = 起点,1 = 终点)。用于仅覆盖边缘的一部分。", + "calibration.advanced.switch_to_simple": "切换到简单模式", + "calibration.advanced.title": "高级校准", + "calibration.border_width": "边框(像素):", + "calibration.border_width.hint": "从屏幕边缘采样多少像素来确定 LED 颜色(1-100)", + "calibration.button.cancel": "取消", + "calibration.button.save": "保存", + "calibration.corner.bottom_left": "设置起始位置:左下角", + "calibration.corner.bottom_right": "设置起始位置:右下角", + "calibration.corner.top_left": "设置起始位置:左上角", + "calibration.corner.top_right": "设置起始位置:右上角", + "calibration.direction": "方向:", + "calibration.direction.clockwise": "顺时针", + "calibration.direction.counterclockwise": "逆时针", + "calibration.direction.toggle": "切换方向", + "calibration.edge.bottom": "切换底部边缘测试 LED", + "calibration.edge.left": "切换左侧边缘测试 LED", + "calibration.edge.right": "切换右侧边缘测试 LED", + "calibration.edge.top": "切换顶部边缘测试 LED", + "calibration.edge_inputs.toggle": "切换边缘 LED 输入", + "calibration.error.css_load_failed": "加载色带源失败", + "calibration.error.led_count_exceeded": "校准的LED超过了LED总数", + "calibration.error.led_count_mismatch": "LED总数必须等于设备LED数量", + "calibration.error.load_failed": "加载校准失败", + "calibration.error.save_failed": "保存校准失败", + "calibration.error.test_toggle_failed": "切换测试边缘失败", + "calibration.failed": "保存校准失败", + "calibration.leds.bottom": "底部 LED:", + "calibration.leds.left": "左侧 LED:", + "calibration.leds.right": "右侧 LED:", + "calibration.leds.top": "顶部 LED:", + "calibration.mode.advanced": "高级", + "calibration.mode.simple": "简单", + "calibration.offset": "LED 偏移:", + "calibration.offset.hint": "从物理 LED 0 到起始角落的距离(沿灯带方向)", + "calibration.overlay_toggle": "叠加层", + "calibration.position.bottom_left": "左下", + "calibration.position.bottom_right": "右下", + "calibration.position.top_left": "左上", + "calibration.position.top_right": "右上", + "calibration.roi": "采集区域(%):", + "calibration.roi.height": "高度 (%)", + "calibration.roi.hint": "仅采集屏幕的此子区域,避免任务栏、游戏 HUD 或黑边干扰边缘颜色。全屏 = X/Y 为 0,宽/高为 100。", + "calibration.roi.width": "宽度 (%)", + "calibration.roi.x": "X (%)", + "calibration.roi.y": "Y (%)", + "calibration.saved": "校准已保存", + "calibration.skip_end": "跳过 LED(末尾):", + "calibration.skip_end.hint": "灯带末尾端关闭的 LED 数量(0 = 无)", + "calibration.skip_start": "跳过 LED(起始):", + "calibration.skip_start.hint": "灯带起始端关闭的 LED 数量(0 = 无)", + "calibration.start_position": "起始位置:", + "calibration.switch_to_advanced": "切换到高级模式", + "calibration.tip.border_width": "从屏幕边缘采样多少像素来确定 LED 颜色", + "calibration.tip.direction": "切换灯带方向(顺时针/逆时针)", + "calibration.tip.led_count": "输入每条边的 LED 数量", + "calibration.tip.offset": "设置 LED 偏移 — 从 LED 0 到起始角落的距离", + "calibration.tip.overlay": "切换屏幕叠加层以查看显示器上的 LED 位置和编号", + "calibration.tip.skip_leds_end": "跳过灯带末尾端的 LED — 被跳过的 LED 保持关闭", + "calibration.tip.skip_leds_start": "跳过灯带起始端的 LED — 被跳过的 LED 保持关闭", + "calibration.tip.span": "拖动绿色条调整覆盖范围", + "calibration.tip.start_corner": "点击角落设置起始位置", + "calibration.tip.test": "点击边缘切换测试 LED", + "calibration.tip.toggle_inputs": "点击 LED 总数切换边缘输入", + "calibration.title": "LED 校准", + "calibration.tutorial.start": "开始教程", + "card_mode.comfortable": "宽松", + "card_mode.compact": "紧凑", + "card_mode.dense": "密集", + "card_mode.row": "列表", + "card_mode.tooltip": "卡片大小", + "color_strip.add": "添加", + "color_strip.animation": "动画", + "color_strip.animation.speed": "速度:", + "color_strip.animation.speed.hint": "动画速度倍数。1.0 ≈ 呼吸效果每秒一个循环;更高值循环更快。", + "color_strip.animation.type": "效果:", + "color_strip.animation.type.breathing": "呼吸", + "color_strip.animation.type.breathing.desc": "平滑的亮度渐入渐出", + "color_strip.animation.type.candle": "烛光", + "color_strip.animation.type.candle.desc": "温暖的类似蜡烛的闪烁光芒", + "color_strip.animation.type.gradient_shift": "渐变移动", + "color_strip.animation.type.gradient_shift.desc": "渐变沿灯带滑动", + "color_strip.animation.type.hint": "要应用的动画效果。", + "color_strip.animation.type.none": "无(无动画效果)", + "color_strip.animation.type.none.desc": "静态颜色,无动画", + "color_strip.animation.type.pulse": "脉冲", + "color_strip.animation.type.pulse.desc": "快速衰减的尖锐亮度脉冲", + "color_strip.animation.type.rainbow_fade": "彩虹渐变", + "color_strip.animation.type.rainbow_fade.desc": "循环整个色相光谱", + "color_strip.animation.type.sparkle": "闪烁", + "color_strip.animation.type.sparkle.desc": "随机 LED 短暂闪亮", + "color_strip.animation.type.strobe": "频闪", + "color_strip.animation.type.strobe.desc": "快速开/关闪烁", + "color_strip.animation.type.wave": "波浪", + "color_strip.animation.type.wave.desc": "沿灯带移动的正弦亮度波", + "color_strip.api_input.endpoints": "推送端点:", + "color_strip.api_input.endpoints.hint": "使用这些 URL 从外部应用程序推送 LED 颜色数据。REST 接受 JSON,WebSocket 接受 JSON 和原始二进制帧。", + "color_strip.api_input.fallback_color": "备用颜色:", + "color_strip.api_input.fallback_color.hint": "超时未收到数据时显示的颜色。启动时和连接丢失后 LED 将显示此颜色。", + "color_strip.api_input.interpolation": "LED 插值:", + "color_strip.api_input.interpolation.hint": "当传入的 LED 数量与设备 LED 数量不同时如何调整大小。线性提供平滑混合,最近邻保持锐利边缘,无则截断或补零。", + "color_strip.api_input.interpolation.linear": "线性", + "color_strip.api_input.interpolation.linear.desc": "LED 之间平滑混合", + "color_strip.api_input.interpolation.nearest": "最近邻", + "color_strip.api_input.interpolation.nearest.desc": "锐利边缘,无混合", + "color_strip.api_input.interpolation.none": "无", + "color_strip.api_input.interpolation.none.desc": "截断或补零", + "color_strip.api_input.save_first": "请先保存源以查看推送端点 URL。", + "color_strip.api_input.timeout": "超时(秒):", + "color_strip.api_input.timeout.hint": "等待新颜色数据多长时间后恢复为备用颜色。设为 0 表示永不超时。", + "color_strip.audio.beat_decay": "节拍衰减:", + "color_strip.audio.beat_decay.hint": "节拍脉冲消退的速度。较低值 = 较长衰减,较高值 = 更灵敏的响应。", + "color_strip.audio.color": "基础颜色:", + "color_strip.audio.color.hint": "VU 表条的低电平颜色。", + "color_strip.audio.color_peak": "峰值颜色:", + "color_strip.audio.color_peak.hint": "VU 表条顶部的高电平颜色。", + "color_strip.audio.mirror": "镜像:", + "color_strip.audio.mirror.hint": "从中心向外镜像频谱:低音在中间,高音在两端。", + "color_strip.audio.palette": "调色板:", + "color_strip.audio.palette.hint": "用于频谱条或节拍脉冲着色的调色板。", + "color_strip.audio.sensitivity": "灵敏度:", + "color_strip.audio.sensitivity.hint": "音频电平的增益倍数。更高值使 LED 对较安静的声音也有反应。", + "color_strip.audio.smoothing": "平滑:", + "color_strip.audio.smoothing.hint": "帧间时间平滑。更高值产生更平滑但反应较慢的视觉效果。", + "color_strip.audio.source": "音频源:", + "color_strip.audio.source.hint": "此可视化的音频源。可以是多声道(设备)或单声道(单通道)源。在源标签页中创建和管理音频源。", + "color_strip.audio.visualization": "可视化:", + "color_strip.audio.visualization.hint": "音频数据如何渲染到 LED。", + "color_strip.audio.viz.beat_pulse": "节拍脉冲", + "color_strip.audio.viz.beat_pulse.desc": "所有LED随节拍脉动", + "color_strip.audio.viz.energy_gradient": "能量渐变", + "color_strip.audio.viz.energy_gradient.desc": "渐变强度跟随音频能量", + "color_strip.audio.viz.pulse_on_beat": "节拍脉动", + "color_strip.audio.viz.pulse_on_beat.desc": "LED随每个检测到的节拍脉动", + "color_strip.audio.viz.spectrum": "频谱分析", + "color_strip.audio.viz.spectrum.desc": "频率条分布在灯带上", + "color_strip.audio.viz.spectrum_bands": "频谱频段", + "color_strip.audio.viz.spectrum_bands.desc": "分组频段分布在灯带上", + "color_strip.audio.viz.strobe_on_drop": "低音闪烁", + "color_strip.audio.viz.strobe_on_drop.desc": "低音下降时闪烁频闪效果", + "color_strip.audio.viz.vu_meter": "VU 表", + "color_strip.audio.viz.vu_meter.desc": "音量填充灯带", + "color_strip.brightness": "亮度:", + "color_strip.brightness.hint": "输出亮度倍数(0=关闭,1=不变,2=加倍)。在颜色提取后应用。", + "color_strip.candlelight.color": "基础颜色:", + "color_strip.candlelight.color.hint": "蜡烛火焰的温暖基础颜色。默认为自然温暖的琥珀色。", + "color_strip.candlelight.intensity": "闪烁强度:", + "color_strip.candlelight.intensity.hint": "蜡烛闪烁程度。低值产生柔和光芒,高值模拟风中的蜡烛。", + "color_strip.candlelight.num_candles": "支蜡烛", + "color_strip.candlelight.num_candles.hint": "灯带上独立蜡烛光源的数量。每支蜡烛有自己的闪烁模式。", + "color_strip.candlelight.num_candles_label": "蜡烛数量:", + "color_strip.candlelight.speed": "闪烁速度:", + "color_strip.candlelight.speed.hint": "闪烁动画的速度。较高的值产生更快、更不安定的火焰。", + "color_strip.clock": "同步时钟:", + "color_strip.clock.hint": "关联同步时钟以在多个源之间同步动画。速度在时钟上控制。", + "color_strip.color_corrections": "色彩校正", + "color_strip.composite.add_layer": "添加图层", + "color_strip.composite.blend_mode": "混合", + "color_strip.composite.blend_mode.add": "叠加", + "color_strip.composite.blend_mode.add.desc": "通过叠加颜色提亮", + "color_strip.composite.blend_mode.difference": "差值", + "color_strip.composite.blend_mode.difference.desc": "绝对颜色差异", + "color_strip.composite.blend_mode.exclusion": "排除", + "color_strip.composite.blend_mode.exclusion.desc": "类似差值,对比度更低", + "color_strip.composite.blend_mode.hard_light": "强光", + "color_strip.composite.blend_mode.hard_light.desc": "强对比度,鲜艳色彩", + "color_strip.composite.blend_mode.multiply": "正片叠底", + "color_strip.composite.blend_mode.multiply.desc": "通过相乘颜色变暗", + "color_strip.composite.blend_mode.normal": "正常", + "color_strip.composite.blend_mode.normal.desc": "标准 Alpha 混合", + "color_strip.composite.blend_mode.overlay": "叠加", + "color_strip.composite.blend_mode.overlay.desc": "暗部相乘,亮部滤色", + "color_strip.composite.blend_mode.override": "覆盖", + "color_strip.composite.blend_mode.override.desc": "黑色=透明,亮色=不透明", + "color_strip.composite.blend_mode.screen": "滤色", + "color_strip.composite.blend_mode.screen.desc": "提亮,正片叠底的反转", + "color_strip.composite.blend_mode.soft_light": "柔光", + "color_strip.composite.blend_mode.soft_light.desc": "柔和对比度调整", + "color_strip.composite.brightness": "亮度", + "color_strip.composite.brightness.none": "无(全亮度)", + "color_strip.composite.enabled": "启用", + "color_strip.composite.error.min_layers": "至少需要 1 个图层", + "color_strip.composite.error.no_source": "每个图层必须选择一个源", + "color_strip.composite.layers": "图层:", + "color_strip.composite.layers.hint": "叠加多个色带源。第一个图层在底部,最后一个在顶部。每个图层可以有自己的混合模式和不透明度。", + "color_strip.composite.layers_count": "个图层", + "color_strip.composite.opacity": "不透明度", + "color_strip.composite.processing": "处理", + "color_strip.composite.range": "LED范围", + "color_strip.composite.range_end": "结束", + "color_strip.composite.range_start": "起始", + "color_strip.composite.reverse": "反转", + "color_strip.composite.source": "源", + "color_strip.created": "色带源已创建", + "color_strip.daylight.latitude": "纬度:", + "color_strip.daylight.latitude.hint": "地理纬度(-90到90)。影响实时模式下的日出/日落时间。", + "color_strip.daylight.real_time": "实时", + "color_strip.daylight.speed": "速度:", + "color_strip.daylight.speed.hint": "循环速度倍数。1.0 = 约4分钟完成一个完整的昼夜循环。", + "color_strip.daylight.use_real_time": "使用实时时间:", + "color_strip.daylight.use_real_time.hint": "启用后,LED颜色匹配计算机的实际时间。速度设置将被忽略。", + "color_strip.delete.confirm": "确定要删除此色带源吗?", + "color_strip.delete.referenced": "无法删除:此源正在被目标使用", + "color_strip.deleted": "色带源已删除", + "color_strip.edit": "编辑", + "color_strip.effect.aurora": "极光", + "color_strip.effect.aurora.desc": "漂移和混合的分层噪声带 — 北极光风格", + "color_strip.effect.color": "流星颜色:", + "color_strip.effect.color.hint": "流星效果的头部颜色。", + "color_strip.effect.fire": "火焰", + "color_strip.effect.fire.desc": "模拟带热量扩散的上升火焰的元胞自动机", + "color_strip.effect.intensity": "强度:", + "color_strip.effect.intensity.hint": "效果强度 — 控制火花率(火焰)、尾迹衰减(流星)或亮度范围(极光)。", + "color_strip.effect.meteor": "流星", + "color_strip.effect.meteor.desc": "明亮头部沿灯带移动,带指数衰减的尾迹", + "color_strip.effect.mirror": "镜像:", + "color_strip.effect.mirror.hint": "反弹模式 — 流星在灯带末端反转方向而不是循环。", + "color_strip.effect.noise": "噪声", + "color_strip.effect.noise.desc": "滚动的分形值噪声映射到调色板", + "color_strip.effect.palette": "调色板:", + "color_strip.effect.palette.hint": "用于将效果值映射到 RGB 颜色的调色板。", + "color_strip.effect.plasma": "等离子", + "color_strip.effect.plasma.desc": "映射到调色板的重叠正弦波 — 经典演示场景效果", + "color_strip.effect.scale": "比例:", + "color_strip.effect.scale.hint": "空间比例 — 波频率(等离子)、缩放级别(噪声)或带宽(极光)。", + "color_strip.effect.speed": "速度:", + "color_strip.effect.speed.hint": "效果动画的速度倍数(0.1 = 非常慢,10.0 = 非常快)。", + "color_strip.effect.type": "效果类型:", + "color_strip.effect.type.hint": "选择程序化算法。", + "color_strip.error.clone_failed": "克隆色带源失败", + "color_strip.error.delete_failed": "删除色带源失败", + "color_strip.error.editor_open_failed": "打开色带编辑器失败", + "color_strip.error.name_required": "请输入名称", + "color_strip.fps": "目标 FPS:", + "color_strip.fps.hint": "LED 颜色更新的目标帧率(10-90)", + "color_strip.frame_interpolation": "帧插值:", + "color_strip.frame_interpolation.hint": "在连续采集帧之间混合,以在采集速率较低时仍以完整目标 FPS 输出。减少慢速环境过渡时的可见阶梯效应。", + "color_strip.game_event.error.no_integration": "请选择游戏集成。", + "color_strip.game_event.event_mappings": "事件映射:", + "color_strip.game_event.event_mappings.hint": "为此源覆盖或添加事件到效果的映射。这些补充集成级别的映射。", + "color_strip.game_event.idle_color": "空闲颜色:", + "color_strip.game_event.idle_color.hint": "没有活动游戏事件时的LED颜色。", + "color_strip.game_event.integration": "游戏集成:", + "color_strip.game_event.integration.hint": "选择为此源提供事件的游戏集成。", + "color_strip.gamma": "伽马:", + "color_strip.gamma.hint": "伽马校正(1=无,\u003c1=更亮的中间调,\u003e1=更暗的中间调)", + "color_strip.gradient.add_stop": "+ 添加色标", + "color_strip.gradient.bidir.hint": "在此色标右侧添加第二种颜色以在渐变中创建硬边。", + "color_strip.gradient.min_stops": "渐变至少需要 2 个色标", + "color_strip.gradient.position": "位置(0.0-1.0)", + "color_strip.gradient.preset": "预设:", + "color_strip.gradient.preset.apply": "应用", + "color_strip.gradient.preset.aurora": "极光", + "color_strip.gradient.preset.cool": "冷色", + "color_strip.gradient.preset.custom": "— 自定义 —", + "color_strip.gradient.preset.deleted": "预设已删除", + "color_strip.gradient.preset.fire": "火焰", + "color_strip.gradient.preset.forest": "森林", + "color_strip.gradient.preset.hint": "加载预定义的渐变调色板。选择预设将替换当前色标。", + "color_strip.gradient.preset.ice": "冰", + "color_strip.gradient.preset.lava": "熔岩", + "color_strip.gradient.preset.neon": "霓虹", + "color_strip.gradient.preset.ocean": "海洋", + "color_strip.gradient.preset.pastel": "柔和", + "color_strip.gradient.preset.rainbow": "彩虹", + "color_strip.gradient.preset.save_button": "保存为预设…", + "color_strip.gradient.preset.save_prompt": "输入预设名称:", + "color_strip.gradient.preset.saved": "预设已保存", + "color_strip.gradient.preset.sunset": "日落", + "color_strip.gradient.preset.warm": "暖色", + "color_strip.gradient.preview": "渐变:", + "color_strip.gradient.preview.hint": "可视预览。点击下方标记轨道添加色标。拖动标记重新定位。", + "color_strip.gradient.stops": "色标:", + "color_strip.gradient.stops.hint": "每个色标在相对位置定义一种颜色(0.0 = 起始,1.0 = 结束)。↔ 按钮添加右侧颜色以在该色标处创建硬边。", + "color_strip.gradient.stops_count": "个色标", + "color_strip.interpolation": "颜色模式:", + "color_strip.interpolation.average": "平均", + "color_strip.interpolation.average.desc": "将所有采样像素混合为平滑颜色", + "color_strip.interpolation.dominant": "主色", + "color_strip.interpolation.dominant.desc": "使用样本中出现最频繁的颜色", + "color_strip.interpolation.hint": "如何从采样的边框像素计算 LED 颜色", + "color_strip.interpolation.median": "中位数", + "color_strip.interpolation.median.desc": "取中间颜色值,减少异常值", + "color_strip.led_count": "LED 数量:", + "color_strip.led_count.hint": "物理灯带上的 LED 总数。屏幕源:0 = 从校准自动获取(未映射到边缘的额外 LED 将为黑色)。静态颜色:设置为与设备 LED 数量匹配。", + "color_strip.leds": "LED 数量", + "color_strip.mapped.add_zone": "+ 添加区域", + "color_strip.mapped.error.no_source": "每个区域必须选择一个源", + "color_strip.mapped.select_source": "搜索源...", + "color_strip.mapped.zone_end": "结束 LED", + "color_strip.mapped.zone_reverse": "反转", + "color_strip.mapped.zone_source": "源", + "color_strip.mapped.zone_start": "起始 LED", + "color_strip.mapped.zones": "区域:", + "color_strip.mapped.zones.hint": "每个区域将色带源映射到特定 LED 范围。区域并排放置 — 区域之间的间隙保持黑色。", + "color_strip.mapped.zones_count": "个区域", + "color_strip.math_wave.add_wave": "+ 添加波形", + "color_strip.math_wave.amplitude": "振幅", + "color_strip.math_wave.error.no_waves": "请至少添加一个波形层。", + "color_strip.math_wave.frequency": "频率", + "color_strip.math_wave.gradient": "颜色渐变:", + "color_strip.math_wave.gradient.hint": "用于着色波形输出的渐变。波形值映射到此渐变的位置。", + "color_strip.math_wave.offset": "偏移", + "color_strip.math_wave.phase": "相位", + "color_strip.math_wave.speed": "速度:", + "color_strip.math_wave.speed.hint": "动画速度倍数。较高的值使波移动更快。", + "color_strip.math_wave.waveform": "波形", + "color_strip.math_wave.waveform.sawtooth": "锯齿波", + "color_strip.math_wave.waveform.sine": "正弦波", + "color_strip.math_wave.waveform.square": "方波", + "color_strip.math_wave.waveform.triangle": "三角波", + "color_strip.math_wave.waves": "波形层:", + "color_strip.math_wave.waves.hint": "添加多个组合在一起的波形层。每个波形有自己的波形、频率、振幅、相位和偏移。", + "color_strip.name": "名称:", + "color_strip.name.placeholder": "墙壁灯带", + "color_strip.notification.app_colors": "应用颜色", + "color_strip.notification.app_colors.add": "+ 添加映射", + "color_strip.notification.app_colors.hint": "每个应用的自定义通知颜色。每行将一个应用名称映射到特定颜色。", + "color_strip.notification.app_colors.label": "颜色映射:", + "color_strip.notification.app_count": "个应用", + "color_strip.notification.app_overrides": "按应用覆盖", + "color_strip.notification.app_overrides.add": "+ 添加覆盖", + "color_strip.notification.app_overrides.app_placeholder": "应用名称", + "color_strip.notification.app_overrides.hint": "为特定应用自定义颜色和声音。每行可设置颜色、声音资源和音量。", + "color_strip.notification.app_overrides.label": "应用覆盖:", + "color_strip.notification.default_color": "默认颜色:", + "color_strip.notification.default_color.hint": "当通知没有应用特定颜色映射时使用的颜色。", + "color_strip.notification.duration": "持续时间(毫秒):", + "color_strip.notification.duration.hint": "通知效果播放的时长(毫秒)。", + "color_strip.notification.effect": "效果:", + "color_strip.notification.effect.flash": "闪烁", + "color_strip.notification.effect.flash.desc": "瞬时点亮,线性衰减", + "color_strip.notification.effect.hint": "通知触发时的视觉效果。闪烁线性衰减,脉冲平滑钟形曲线,扫描从左到右填充后衰减。", + "color_strip.notification.effect.pulse": "脉冲", + "color_strip.notification.effect.pulse.desc": "平滑钟形发光", + "color_strip.notification.effect.sweep": "扫描", + "color_strip.notification.effect.sweep.desc": "从左到右填充然后消失", + "color_strip.notification.endpoint": "Webhook 端点:", + "color_strip.notification.endpoint.hint": "使用此 URL 从外部系统触发通知。POST 请求可选 JSON:{\"app\": \"AppName\", \"color\": \"#FF0000\"}。", + "color_strip.notification.filter_list": "应用列表:", + "color_strip.notification.filter_list.hint": "每行一个应用名称。使用「浏览」从运行中的进程中选择。", + "color_strip.notification.filter_list.placeholder": "Discord\nSlack\nTelegram", + "color_strip.notification.filter_mode": "应用过滤:", + "color_strip.notification.filter_mode.blacklist": "黑名单", + "color_strip.notification.filter_mode.blacklist.desc": "排除列出的应用", + "color_strip.notification.filter_mode.hint": "按应用名称过滤通知。关闭=接受全部,白名单=仅列出的应用,黑名单=排除列出的应用。", + "color_strip.notification.filter_mode.off": "关闭", + "color_strip.notification.filter_mode.off.desc": "接受所有通知", + "color_strip.notification.filter_mode.whitelist": "白名单", + "color_strip.notification.filter_mode.whitelist.desc": "仅列出的应用", + "color_strip.notification.history.empty": "尚未捕获任何通知", + "color_strip.notification.history.error": "加载通知历史失败", + "color_strip.notification.history.filtered": "过滤的流数量", + "color_strip.notification.history.fired": "触发的流数量", + "color_strip.notification.history.hint": "监听器捕获的最近OS通知(最新在前),最多50条。", + "color_strip.notification.history.refresh": "刷新", + "color_strip.notification.history.title": "通知历史", + "color_strip.notification.history.unavailable": "此平台不支持OS通知监听器", + "color_strip.notification.history.unknown_app": "未知应用", + "color_strip.notification.os_listener": "监听系统通知:", + "color_strip.notification.os_listener.hint": "启用后,当桌面通知出现时(Windows toast / Linux D-Bus),此源会自动触发。需要应用具有通知访问权限。", + "color_strip.notification.save_first": "请先保存源以查看 Webhook 端点 URL。", + "color_strip.notification.search_apps": "搜索通知应用…", + "color_strip.notification.sound": "声音", + "color_strip.notification.sound.app_name_placeholder": "应用名称", + "color_strip.notification.sound.app_sounds": "按应用声音:", + "color_strip.notification.sound.app_sounds.add": "+ 添加覆盖", + "color_strip.notification.sound.app_sounds.hint": "为特定应用覆盖声音和音量。空声音 = 静音该应用。", + "color_strip.notification.sound.asset": "声音资源:", + "color_strip.notification.sound.asset.hint": "选择通知触发时播放的声音资源。留空表示静音。", + "color_strip.notification.sound.none": "无(静音)", + "color_strip.notification.sound.search": "搜索声音…", + "color_strip.notification.sound.volume": "音量:", + "color_strip.notification.sound.volume.hint": "通知声音的全局音量(0–100%)。", + "color_strip.notification.test": "测试通知", + "color_strip.notification.test.error": "发送通知失败", + "color_strip.notification.test.no_streams": "此源没有运行中的流", + "color_strip.notification.test.ok": "通知已发送", + "color_strip.palette.aurora": "极光", + "color_strip.palette.fire": "火焰", + "color_strip.palette.forest": "森林", + "color_strip.palette.ice": "冰", + "color_strip.palette.lava": "熔岩", + "color_strip.palette.ocean": "海洋", + "color_strip.palette.rainbow": "彩虹", + "color_strip.palette.sunset": "日落", + "color_strip.picture_source": "图片源:", + "color_strip.picture_source.hint": "用作 LED 颜色计算输入的屏幕采集源", + "color_strip.preview.connected": "已连接", + "color_strip.preview.connecting": "连接中...", + "color_strip.preview.not_connected": "未连接", + "color_strip.preview.save_first": "请先保存源——预览需要校准数据", + "color_strip.preview.title": "实时预览", + "color_strip.preview.unsupported": "此源类型不支持预览", + "color_strip.processed.error.no_input": "请选择输入源", + "color_strip.processed.input": "源:", + "color_strip.processed.input.hint": "将被处理的色带源", + "color_strip.processed.template": "处理模板:", + "color_strip.processed.template.hint": "应用于输入源输出的滤镜链", + "color_strip.saturation": "饱和度:", + "color_strip.saturation.hint": "颜色饱和度(0=灰度,1=不变,2=双倍饱和度)", + "color_strip.select_type": "选择色带类型", + "color_strip.single_color": "颜色:", + "color_strip.single_color.hint": "将发送到灯带上所有 LED 的纯色。", + "color_strip.smoothing": "平滑:", + "color_strip.smoothing.hint": "帧间时间混合(0=无,1=完全)。减少闪烁。", + "color_strip.test.apply": "应用", + "color_strip.test.composite": "合成", + "color_strip.test.connecting": "连接中...", + "color_strip.test.error": "无法连接到预览流", + "color_strip.test.fps": "FPS:", + "color_strip.test.led_count": "LED数量:", + "color_strip.test.receive_fps": "接收帧率", + "color_strip.test.title": "预览测试", + "color_strip.test_device": "测试设备:", + "color_strip.test_device.hint": "选择一个设备,在点击边缘切换时发送测试像素", + "color_strip.type": "类型:", + "color_strip.type.api_input": "API 输入", + "color_strip.type.api_input.desc": "从外部应用接收颜色", + "color_strip.type.api_input.hint": "通过 REST POST 或 WebSocket 从外部客户端接收原始 LED 颜色数组。用于与自定义软件、家庭自动化或任何能发送 HTTP 请求的系统集成。", + "color_strip.type.audio": "音频响应", + "color_strip.type.audio.desc": "由音频输入驱动LED", + "color_strip.type.audio.hint": "LED 颜色由实时音频输入驱动 — 系统音频或麦克风。", + "color_strip.type.candlelight": "烛光", + "color_strip.type.candlelight.desc": "逼真的烛光闪烁模拟", + "color_strip.type.candlelight.hint": "在所有LED上模拟逼真的蜡烛闪烁,具有温暖色调和有机闪烁模式。", + "color_strip.type.composite": "组合", + "color_strip.type.composite.desc": "叠加和混合多个源", + "color_strip.type.composite.hint": "将多个色带源作为图层叠加,支持混合模式和不透明度。", + "color_strip.type.daylight": "日光循环", + "color_strip.type.daylight.desc": "模拟24小时自然日光变化", + "color_strip.type.daylight.hint": "模拟太阳在24小时内的色温变化——从温暖的日出到冷白的日光,再到温暖的日落和昏暗的夜晚。", + "color_strip.type.effect": "效果", + "color_strip.type.effect.desc": "程序化效果:火焰、等离子、极光", + "color_strip.type.effect.hint": "实时生成的程序化 LED 效果(火焰、流星、等离子、噪声、极光)。", + "color_strip.type.game_event": "游戏事件", + "color_strip.type.game_event.desc": "由游戏事件触发的LED效果", + "color_strip.type.gradient": "渐变", + "color_strip.type.gradient.desc": "LED上的平滑颜色过渡", + "color_strip.type.hint": "图片源从屏幕采集推导 LED 颜色。单色用单一颜色填充所有 LED。渐变在所有 LED 上分布颜色渐变。颜色循环平滑循环用户定义的颜色列表。组合将多个源作为混合图层叠加。音频响应从实时音频输入驱动 LED。API 输入通过 REST 或 WebSocket 从外部客户端接收原始 LED 颜色。", + "color_strip.type.mapped": "映射", + "color_strip.type.mapped.desc": "为LED区域分配源", + "color_strip.type.mapped.hint": "将不同色带源分配到不同 LED 范围(区域)。与组合的图层混合不同,映射将源并排放置。", + "color_strip.type.math_wave": "数学波", + "color_strip.type.math_wave.desc": "使用渐变色映射的数学波形生成器", + "color_strip.type.notification": "通知", + "color_strip.type.notification.desc": "通过Webhook触发的一次性效果", + "color_strip.type.notification.hint": "通过 Webhook 触发时显示一次性视觉效果(闪烁、脉冲、扫描)。设计为组合源中的叠加层。", + "color_strip.type.picture": "图片源", + "color_strip.type.picture.desc": "从屏幕捕获获取颜色", + "color_strip.type.picture_advanced": "多显示器", + "color_strip.type.picture_advanced.desc": "跨显示器的线条校准", + "color_strip.type.processed": "已处理", + "color_strip.type.processed.desc": "将处理模板应用于另一个源", + "color_strip.type.processed.hint": "包装现有色带源并通过滤镜链处理其输出。", + "color_strip.type.single_color": "单色", + "color_strip.type.single_color.desc": "单色填充", + "color_strip.type.weather": "天气", + "color_strip.type.weather.desc": "天气感应环境色彩", + "color_strip.type.weather.hint": "将实时天气状况映射为环境LED颜色。需要天气源实体。", + "color_strip.updated": "色带源已更新", + "color_strip.weather.error.no_source": "请选择天气源", + "color_strip.weather.source": "天气源:", + "color_strip.weather.source.hint": "用于环境颜色的天气数据源。请先在天气标签页中创建。", + "color_strip.weather.speed": "动画速度:", + "color_strip.weather.speed.hint": "环境色彩漂移动画速度。越高越快。", + "color_strip.weather.temperature_influence": "温度影响:", + "color_strip.weather.temperature_influence.hint": "当前温度对调色板冷暖偏移程度。0=纯天气颜色,1=强偏移。", + "common.apply": "应用", + "common.back": "返回", + "common.cancel": "取消", + "common.card_color": "卡片颜色", + "common.clone": "克隆", + "common.daylight_tz.detected": "浏览器检测", + "common.daylight_tz.detected_label": "自动检测", + "common.daylight_tz.region.africa": "非洲", + "common.daylight_tz.region.americas": "美洲", + "common.daylight_tz.region.asia": "亚洲", + "common.daylight_tz.region.europe": "欧洲", + "common.daylight_tz.region.me": "中东", + "common.daylight_tz.region.pacific": "太平洋", + "common.daylight_tz.region.utc": "世界标准", + "common.daylight_tz.search": "搜索时区、城市、地区…", + "common.daylight_tz.system": "系统默认", + "common.daylight_tz.system_desc": "服务器时钟 — 不覆盖", + "common.delete": "删除", + "common.edit": "编辑", + "common.hide": "隐藏卡片", + "common.led_preview": "LED 预览", + "common.loading": "加载中...", + "common.more_actions": "更多操作", + "common.none": "无", + "common.none_no_cspt": "无(无处理模板)", + "common.none_no_input": "无(无输入源)", + "common.none_own_speed": "无(使用自身速度)", + "common.remove": "移除", + "common.start": "启动", + "common.stop": "停止", + "common.undo": "撤销", + "confirm.no": "否", + "confirm.stop_all": "停止所有运行中的目标?", + "confirm.title": "确认操作", + "confirm.turn_off_device": "关闭此设备?", + "confirm.turn_off_ha_light": "关闭所有映射的灯具?", + "confirm.yes": "是", + "css_processing.add": "添加色带处理模板", + "css_processing.created": "色带处理模板已创建", + "css_processing.delete.confirm": "确定要删除此色带处理模板吗?", + "css_processing.deleted": "色带处理模板已删除", + "css_processing.description_label": "描述(可选):", + "css_processing.description_placeholder": "描述此模板...", + "css_processing.edit": "编辑色带处理模板", + "css_processing.error.clone_failed": "克隆色带处理模板失败", + "css_processing.error.delete": "删除色带处理模板出错", + "css_processing.error.load": "加载色带处理模板出错", + "css_processing.error.required": "请填写所有必填字段", + "css_processing.name": "模板名称:", + "css_processing.name_placeholder": "我的色带处理模板", + "css_processing.title": "色带处理模板", + "css_processing.updated": "色带处理模板已更新", + "dashboard.customize.anim": "动画", + "dashboard.customize.anim.full": "完整", + "dashboard.customize.anim.off": "关闭", + "dashboard.customize.anim.reduced": "减少", + "dashboard.customize.cell_drag_help": "拖动行可更改仪表盘性能条中单元格的顺序。", + "dashboard.customize.collapse_default.off": "默认展开", + "dashboard.customize.collapse_default.on": "默认折叠", + "dashboard.customize.density.comfortable": "宽松", + "dashboard.customize.density.compact": "紧凑", + "dashboard.customize.density.dense": "密集", + "dashboard.customize.drag_help": "拖动行重新排序,或使用 ↑/↓ 按钮。", + "dashboard.customize.export": "导出", + "dashboard.customize.exported": "布局已导出", + "dashboard.customize.fixed_top": "固定在顶部", + "dashboard.customize.global": "全局", + "dashboard.customize.hide": "隐藏", + "dashboard.customize.import": "导入", + "dashboard.customize.import_failed": "导入布局失败", + "dashboard.customize.imported": "布局已导入", + "dashboard.customize.mode.app": "应用", + "dashboard.customize.mode.both": "两者", + "dashboard.customize.mode.inherit": "继承", + "dashboard.customize.mode.system": "系统", + "dashboard.customize.mode_short": "模式", + "dashboard.customize.modified": "已修改", + "dashboard.customize.perf_cells": "性能面板", + "dashboard.customize.perf_mode": "性能模式", + "dashboard.customize.preset.diagnostics": "诊断", + "dashboard.customize.preset.operator": "操作员", + "dashboard.customize.preset.showrunner": "演出", + "dashboard.customize.preset.studio": "工作室", + "dashboard.customize.preset.tv": "电视", + "dashboard.customize.presets": "预设", + "dashboard.customize.reset": "重置", + "dashboard.customize.reset_confirm": "将仪表盘布局重置为「工作室」预设?", + "dashboard.customize.scale": "Y 轴刻度", + "dashboard.customize.scale_short": "刻度", + "dashboard.customize.sections": "分区", + "dashboard.customize.show": "显示", + "dashboard.customize.title": "自定义仪表盘", + "dashboard.customize.width": "宽度", + "dashboard.customize.width.centered": "居中", + "dashboard.customize.width.full": "全宽", + "dashboard.customize.width.narrow": "窄", + "dashboard.customize.window": "采样窗口", + "dashboard.customize.window_short": "窗口", + "dashboard.customize.yscale.auto": "自动", + "dashboard.customize.yscale.fixed": "固定", + "dashboard.customize.yscale.log": "对数", + "dashboard.device": "设备", + "dashboard.device.chip": "芯片:", + "dashboard.error.automation_toggle_failed": "切换自动化失败", + "dashboard.error.start_failed": "启动处理失败", + "dashboard.error.stop_all": "停止所有目标失败", + "dashboard.error.stop_failed": "停止处理失败", + "dashboard.errors": "错误", + "dashboard.failed": "加载仪表盘失败", + "dashboard.fps": "FPS", + "dashboard.no_targets": "尚未配置目标", + "dashboard.perf.active_patches": "活动通道", + "dashboard.perf.color": "图表颜色", + "dashboard.perf.cpu": "CPU", + "dashboard.perf.device_latency": "设备延迟", + "dashboard.perf.devices": "设备", + "dashboard.perf.errors": "错误", + "dashboard.perf.gpu": "GPU", + "dashboard.perf.mode.app": "应用", + "dashboard.perf.mode.both": "全部", + "dashboard.perf.mode.system": "系统", + "dashboard.perf.network": "网络", + "dashboard.perf.patches.empty.idle": "准备就绪", + "dashboard.perf.patches.empty.none": "暂无通道", + "dashboard.perf.ram": "内存", + "dashboard.perf.send_timing": "发送耗时", + "dashboard.perf.temp": "温度", + "dashboard.perf.temp.install_lhm": "Windows 没有内置的 CPU 温度 API。请安装 LibreHardwareMonitor 并启用“Publish to WMI”以在此处查看实时读数。", + "dashboard.perf.total_capture_fps": "总输入帧率", + "dashboard.perf.total_capture_fps_actual": "总采集帧率", + "dashboard.perf.total_fps": "总帧率", + "dashboard.perf.unavailable": "不可用", + "dashboard.poll_interval": "刷新间隔", + "dashboard.recent_activity.view_all": "查看全部", + "dashboard.section.automations": "自动化", + "dashboard.section.performance": "系统性能", + "dashboard.section.playlists": "播放列表", + "dashboard.section.recent_activity": "最近活动", + "dashboard.section.running": "运行中", + "dashboard.section.scenes": "场景预设", + "dashboard.section.stopped": "已停止", + "dashboard.section.sync_clocks": "同步时钟", + "dashboard.section.targets": "通道", + "dashboard.stop_all": "全部停止", + "dashboard.targets": "目标", + "dashboard.title": "仪表盘", + "dashboard.type.kc": "关键颜色", + "dashboard.type.led": "LED", + "dashboard.uptime": "运行时长", + "demo.badge": "演示", + "demo.banner": "您正处于演示模式 — 所有设备和数据均为虚拟。未使用任何真实硬件。", + "device.added": "设备添加成功", + "device.baud_rate": "波特率:", + "device.baud_rate.hint": "串口通信速率。越高 FPS 越高,但需要与 Arduino 程序匹配。", + "device.ble.family": "协议:", + "device.ble.family.govee.desc": "Govee H6xxx 灯带 — 仅支持未加密固件", + "device.ble.family.hint": "控制器使用哪种 BLE 协议。按你平时使用的手机应用选择。", + "device.ble.family.sp110e.desc": "可编址控制器 — LED Hue / SP110E 应用", + "device.ble.family.triones.desc": "单色控制器 — HappyLighting / LEDnet", + "device.ble.family.zengge.desc": "单色控制器 — iLightsIn / Mohuan", + "device.ble.govee_key": "Govee AES 密钥(hex):", + "device.ble.govee_key.hint": "可选。新版 Govee 固件需要按型号的 AES 密钥 — 老固件留空即可。", + "device.ble.govee_key.placeholder": "32位十六进制,如 0102…1f20", + "device.ble.url": "BLE 地址:", + "device.ble.url.hint": "MAC 地址(Windows/Linux)或 UUID(macOS),加前缀 ble://", + "device.brightness": "亮度", + "device.button.add": "添加设备", + "device.button.calibrate": "校准", + "device.button.capture_settings": "采集设置", + "device.button.ping": "Ping 设备", + "device.button.power_off": "关闭", + "device.button.remove": "移除", + "device.button.settings": "常规设置", + "device.button.start": "启动", + "device.button.stop": "停止", + "device.button.stream_selector": "源设置", + "device.button.webui": "打开设备 Web UI", + "device.chroma.device_type": "Peripheral Type:", + "device.chroma.device_type.hint": "Which Razer peripheral to control via Chroma SDK", + "device.css_processing_template": "色带处理模板:", + "device.css_processing_template.hint": "应用于此设备所有色带输出的默认处理模板", + "device.ddp.color_order.bgr.desc": "BGR byte order", + "device.ddp.color_order.brg.desc": "BRG byte order", + "device.ddp.color_order.gbr.desc": "GBR byte order", + "device.ddp.color_order.grb.desc": "WS2812/WS2812B native order", + "device.ddp.color_order.rbg.desc": "RBG byte order", + "device.ddp.color_order.rgb.desc": "Standard RGB byte order", + "device.ddp.url": "IP 地址:", + "device.ddp.url.hint": "DDP 接收器地址。端口默认为 4048。", + "device.ddp.url.placeholder": "192.168.1.50", + "device.ddp_color_order": "颜色顺序:", + "device.ddp_color_order.hint": "线路上的通道字节顺序。大多数 DDP 接收器期望 RGB。", + "device.ddp_destination_id": "目标 ID:", + "device.ddp_destination_id.hint": "DDP 目标标识符(1 = 显示)。", + "device.ddp_port": "DDP 端口:", + "device.ddp_port.hint": "UDP 端口(0 = 协议默认值 4048)。", + "device.display": "显示器:", + "device.dmx.url": "IP 地址:", + "device.dmx.url.hint": "DMX 节点的 IP 地址(例如 192.168.1.50)", + "device.dmx.url.placeholder": "192.168.1.50", + "device.dmx_protocol": "DMX 协议:", + "device.dmx_protocol.artnet.desc": "UDP 单播,端口 6454", + "device.dmx_protocol.hint": "Art-Net 使用 UDP 端口 6454,sACN (E1.31) 使用 UDP 端口 5568", + "device.dmx_protocol.sacn.desc": "组播/单播,端口 5568", + "device.dmx_start_channel": "起始通道:", + "device.dmx_start_channel.hint": "universe 中的第一个 DMX 通道 (1-512)", + "device.dmx_start_universe": "起始 Universe:", + "device.dmx_start_universe.hint": "第一个 DMX universe (0-32767)。超过 170 个 LED 时自动使用多个 universe。", + "device.error.brightness": "更新亮度失败", + "device.error.clone_failed": "克隆设备失败", + "device.error.load_failed": "加载设备失败", + "device.error.power_off_failed": "关闭设备失败", + "device.error.remove_failed": "移除设备失败", + "device.error.required": "请填写所有字段", + "device.error.save": "保存设置失败", + "device.error.settings_load_failed": "加载设备设置失败", + "device.error.update": "更新设备失败", + "device.espnow.channel": "WiFi Channel:", + "device.espnow.channel.hint": "WiFi channel (1-14). Must match the receiver\u0027s channel.", + "device.espnow.peer_mac": "Peer MAC:", + "device.espnow.peer_mac.hint": "MAC address of the remote ESP32 receiver (e.g. AA:BB:CC:DD:EE:FF)", + "device.fps": "FPS:", + "device.gamesense.device_type": "Peripheral Type:", + "device.gamesense.device_type.hint": "Which SteelSeries peripheral to control via GameSense", + "device.gamesense.peripheral.headset": "Headset", + "device.gamesense.peripheral.headset.desc": "Headset earcup lighting", + "device.gamesense.peripheral.indicator": "Indicator", + "device.gamesense.peripheral.indicator.desc": "OLED/LED status indicator", + "device.gamesense.peripheral.keyboard": "Keyboard", + "device.gamesense.peripheral.keyboard.desc": "Per-key RGB illumination", + "device.gamesense.peripheral.mouse": "Mouse", + "device.gamesense.peripheral.mouse.desc": "Mouse RGB zones", + "device.gamesense.peripheral.mousepad": "Mousepad", + "device.gamesense.peripheral.mousepad.desc": "Mousepad edge lighting zones", + "device.govee.url": "IP 地址:", + "device.govee.url.hint": "Govee 设备的局域网 IP。请先在 Govee Home 应用中启用 LAN Control(设备 → ⚙ → LAN Control),否则灯泡不会响应。", + "device.govee.url.placeholder": "192.168.1.50", + "device.govee_min_interval": "最小更新间隔:", + "device.govee_min_interval.hint": "客户端命令速率限制(毫秒)。UDP 即发即忘可处理快速更新;默认 50 毫秒 ≈ 20 Hz。", + "device.group.add_child": "+ 添加设备", + "device.group.children": "子设备:", + "device.group.children.hint": "选择要包含在组中的设备。顺序在序列模式下很重要。", + "device.group.error.no_children": "请至少添加一个子设备到组中。", + "device.group.mode": "组模式:", + "device.group.mode.hint": "序列模式将LED首尾相连。独立模式将完整灯带镜像到每个设备。", + "device.group.mode.independent": "独立", + "device.group.mode.independent.desc": "将完整灯带独立镜像到每个设备", + "device.group.mode.sequence": "序列", + "device.group.mode.sequence.desc": "将LED首尾相连合并为一条长灯带", + "device.group.move_down": "下移", + "device.group.move_up": "上移", + "device.group.select_device": "选择设备", + "device.health.checking": "检测中...", + "device.health.offline": "离线", + "device.health.online": "在线", + "device.health.streaming_unreachable": "流传输期间不可达", + "device.hue.client_key": "Client Key:", + "device.hue.client_key.hint": "Entertainment API client key (hex string from pairing)", + "device.hue.group_id": "Entertainment Group:", + "device.hue.group_id.hint": "Entertainment configuration ID from your Hue bridge", + "device.hue.url": "Bridge IP:", + "device.hue.url.hint": "IP address of your Hue bridge", + "device.hue.username": "Bridge Username:", + "device.hue.username.hint": "Hue bridge application key from pairing", + "device.icon.add": "添加", + "device.icon.audio_file": "音频文件", + "device.icon.bath": "浴室", + "device.icon.battery": "电池", + "device.icon.bot": "机器人", + "device.icon.briefcase": "公文包", + "device.icon.bulb_off": "灯泡关闭", + "device.icon.calendar": "日历", + "device.icon.camcorder": "摄像机", + "device.icon.candle": "蜡烛", + "device.icon.cat.all": "全部", + "device.icon.cat.ambience": "氛围", + "device.icon.cat.controls": "控件", + "device.icon.cat.hardware": "硬件", + "device.icon.cat.lighting": "照明", + "device.icon.cat.media": "媒体", + "device.icon.cat.nature": "自然", + "device.icon.cat.office": "办公", + "device.icon.cat.rooms": "房间", + "device.icon.cat.signal": "信号", + "device.icon.cat.status": "状态", + "device.icon.cat.weather": "天气", + "device.icon.ceiling": "吸顶灯", + "device.icon.change": "更换图标…", + "device.icon.check": "勾选", + "device.icon.checklist": "清单", + "device.icon.choose": "选择图标…", + "device.icon.clock": "时钟", + "device.icon.code": "代码", + "device.icon.coffee": "咖啡", + "device.icon.color_toggle": "颜色", + "device.icon.desk_lamp": "台灯", + "device.icon.dim": "柔光", + "device.icon.disc": "光盘", + "device.icon.doc": "文档", + "device.icon.empty": "无匹配的图标。", + "device.icon.entity.asset": "资源", + "device.icon.entity.audio_processing_template": "音频处理模板", + "device.icon.entity.audio_source": "音频源", + "device.icon.entity.audio_template": "音频模板", + "device.icon.entity.automation": "自动化", + "device.icon.entity.capture_template": "捕获模板", + "device.icon.entity.color_strip_source": "色带", + "device.icon.entity.cspt": "色带处理模板", + "device.icon.entity.device": "设备", + "device.icon.entity.game_integration": "游戏集成", + "device.icon.entity.gradient": "渐变", + "device.icon.entity.ha_light_target": "HA 灯目标", + "device.icon.entity.ha_source": "Home Assistant 源", + "device.icon.entity.http_endpoint": "HTTP 端点", + "device.icon.entity.mqtt_source": "MQTT 源", + "device.icon.entity.pattern_template": "图案模板", + "device.icon.entity.picture_source": "画面源", + "device.icon.entity.pp_template": "后处理模板", + "device.icon.entity.scene_playlist": "播放列表", + "device.icon.entity.scene_preset": "场景预设", + "device.icon.entity.sync_clock": "同步时钟", + "device.icon.entity.target": "LED 目标", + "device.icon.entity.value_source": "数值源", + "device.icon.entity.weather_source": "天气源", + "device.icon.error.save_failed": "保存图标失败", + "device.icon.eyebrow": "卡片图标", + "device.icon.flashlight": "手电筒", + "device.icon.flower": "花", + "device.icon.fog": "雾", + "device.icon.for": "用于", + "device.icon.garage": "车库", + "device.icon.hashtag": "话题", + "device.icon.help": "帮助", + "device.icon.hint": "↵ 应用 · Esc 取消", + "device.icon.image": "图片", + "device.icon.inherited_from": "继承自 %s", + "device.icon.key": "钥匙", + "device.icon.kitchen": "厨房", + "device.icon.laptop": "笔记本电脑", + "device.icon.leaf": "叶子", + "device.icon.link": "链接", + "device.icon.lock": "锁定", + "device.icon.mail": "邮件", + "device.icon.mcu": "微控制器", + "device.icon.mountain": "山", + "device.icon.off": "已禁用", + "device.icon.ok": "正常", + "device.icon.outdoor": "户外", + "device.icon.override_inherited": "覆盖继承的图标…", + "device.icon.package": "设备模块", + "device.icon.phone": "手机", + "device.icon.point": "点光源", + "device.icon.projector": "投影仪", + "device.icon.pulse": "脉冲", + "device.icon.rain": "雨", + "device.icon.recent": "最近使用", + "device.icon.refresh": "刷新", + "device.icon.remove": "移除图标", + "device.icon.ring": "环形灯", + "device.icon.router": "路由器", + "device.icon.saved": "图标已保存", + "device.icon.search": "搜索", + "device.icon.search.placeholder": "搜索图标…", + "device.icon.send": "发送", + "device.icon.server": "服务器", + "device.icon.settings": "设置", + "device.icon.shield": "护盾", + "device.icon.show": "显示", + "device.icon.slider": "滑块", + "device.icon.snow": "雪", + "device.icon.snowflake": "雪花", + "device.icon.sprout": "嫩芽", + "device.icon.sunrise": "日出", + "device.icon.sunset": "日落", + "device.icon.switch": "开关", + "device.icon.target": "目标", + "device.icon.thunder": "雷电", + "device.icon.title": "选择图标", + "device.icon.tool": "工具", + "device.icon.trash": "回收站", + "device.icon.tree": "树", + "device.icon.trend": "趋势", + "device.icon.umbrella": "雨伞", + "device.icon.undo": "撤销", + "device.icon.use_inherited": "使用继承的", + "device.icon.wall_light": "壁灯", + "device.icon.warning": "警告", + "device.icon.watch": "智能手表", + "device.icon.water_drops": "水滴", + "device.icon.waves": "波浪", + "device.icon.webcam": "网络摄像头", + "device.icon.wind": "风", + "device.last_seen.days": "%d天前", + "device.last_seen.hours": "%d小时前", + "device.last_seen.just_now": "刚刚", + "device.last_seen.label": "最近检测", + "device.last_seen.minutes": "%d分钟前", + "device.last_seen.seconds": "%d秒前", + "device.led_count": "LED 数量:", + "device.led_count.hint": "设备中配置的 LED 数量", + "device.led_count.hint.auto": "从设备自动检测", + "device.led_count_manual.hint": "灯带上的 LED 数量(必须与 Arduino 程序匹配)", + "device.led_type": "LED 类型:", + "device.led_type.hint": "RGB(3通道)或 RGBW(4通道,带独立白色)", + "device.lifx.url": "IP 地址:", + "device.lifx.url.hint": "LIFX 灯泡的局域网 IP。UDP 端口 56700 为协议默认值。", + "device.lifx.url.placeholder": "192.168.1.50", + "device.lifx_min_interval": "最小更新间隔:", + "device.lifx_min_interval.hint": "客户端命令速率限制(毫秒)。LIFX 建议 ≤20 cmd/sec;默认 50 毫秒符合该上限。", + "device.metrics.actual_fps": "实际 FPS", + "device.metrics.current_fps": "当前 FPS", + "device.metrics.device_fps": "设备刷新率", + "device.metrics.errors": "错误", + "device.metrics.frames": "帧数", + "device.metrics.frames_skipped": "已跳过", + "device.metrics.keepalive": "心跳", + "device.metrics.potential_fps": "潜在 FPS", + "device.metrics.target_fps": "目标 FPS", + "device.metrics.timing": "管线时序:", + "device.metrics.uptime": "运行时长", + "device.mqtt_source": "MQTT 代理:", + "device.mqtt_source.hint": "此设备发布到哪个 MQTT 代理。在集成 → MQTT 源中管理代理。留空则使用第一个可用代理。", + "device.mqtt_source.none": "— 第一个可用代理", + "device.mqtt_topic": "MQTT 主题:", + "device.mqtt_topic.hint": "用于发布像素数据的 MQTT 主题路径(例如 mqtt://ledgrab/device/name)", + "device.mqtt_topic.placeholder": "mqtt://ledgrab/device/客厅", + "device.name": "设备名称:", + "device.name.placeholder": "客厅电视", + "device.nanoleaf.pair.instructions": "请按住 Nanoleaf 控制器上的电源按钮 5 秒,直到 LED 闪烁,然后点击「开始」。控制器将打开 30 秒的配对窗口。", + "device.nanoleaf.pair_button": "配对设备", + "device.nanoleaf.paired": "已配对", + "device.nanoleaf.token.label": "认证令牌:", + "device.nanoleaf.url": "IP 地址:", + "device.nanoleaf.url.hint": "Nanoleaf 控制器的局域网 IP。HTTP 端口 16021 由协议固定。", + "device.nanoleaf.url.placeholder": "192.168.1.50", + "device.nanoleaf_min_interval": "最小更新间隔:", + "device.nanoleaf_min_interval.hint": "客户端命令速率限制(毫秒)。默认 100 毫秒 ≈ 10 Hz;HTTP 请求开销将实际上限限制在约 20 Hz。", + "device.opc.url": "IP 地址:", + "device.opc.url.hint": "OPC 接收器地址。TCP 端口默认为 7890。", + "device.opc.url.placeholder": "192.168.1.50", + "device.opc_channel": "通道:", + "device.opc_channel.hint": "OPC 通道(0 = 广播到服务器所有通道,1-255 = 特定输出)。", + "device.openrgb.added_multiple": "已添加 {count} 个设备", + "device.openrgb.mode": "区域模式:", + "device.openrgb.mode.combined": "合并灯带", + "device.openrgb.mode.hint": "合并模式将所有区域作为一条连续 LED 灯带。独立模式让每个区域独立渲染完整效果。", + "device.openrgb.mode.separate": "独立区域", + "device.openrgb.url": "OpenRGB URL:", + "device.openrgb.url.hint": "OpenRGB 服务器地址(例如 openrgb://localhost:6742/0)", + "device.openrgb.zone": "区域:", + "device.openrgb.zone.error": "加载区域失败", + "device.openrgb.zone.hint": "选择要控制的 LED 区域(全部不选则控制所有区域)", + "device.openrgb.zone.loading": "加载区域中…", + "device.ping.error": "Ping 失败", + "device.ping.offline": "设备离线", + "device.ping.online": "在线 ({ms}ms)", + "device.power.off_success": "设备已关闭", + "device.remove.confirm": "确定要移除此设备吗?", + "device.removed": "设备已移除", + "device.scan": "自动发现", + "device.scan.already_added": "已添加", + "device.scan.empty": "未找到设备", + "device.scan.error": "网络扫描失败", + "device.scan.selected": "设备已选择", + "device.select_type": "选择设备类型", + "device.send_latency": "发送延迟(毫秒):", + "device.send_latency.hint": "每帧模拟网络/串口延迟(毫秒)", + "device.serial_port": "串口:", + "device.serial_port.hint": "选择 Adalight 设备的 COM 端口", + "device.serial_port.none": "未找到串口", + "device.serial_port.select": "选择端口...", + "device.spi.led_type": "LED Chipset:", + "device.spi.led_type.hint": "Type of addressable LED strip connected to the GPIO/SPI pin", + "device.spi.led_type.sk6812.desc": "Samsung LED, 800 KHz, 3-wire RGB", + "device.spi.led_type.sk6812_rgbw.desc": "SK6812 with dedicated white channel", + "device.spi.led_type.ws2811.desc": "External driver IC, 400 KHz, 12V strips", + "device.spi.led_type.ws2812.desc": "Original WS2812, 800 KHz, 3-wire RGB", + "device.spi.led_type.ws2812b.desc": "Most common, 800 KHz data, 3-wire RGB", + "device.spi.speed": "SPI Speed (Hz):", + "device.spi.speed.hint": "SPI clock speed. 800000 Hz for WS2812, 2400000 Hz for APA102.", + "device.spi.url": "GPIO/SPI Path:", + "device.spi.url.hint": "GPIO pin or SPI device path (e.g. spi://gpio:18)", + "device.started": "处理已启动", + "device.status.connected": "已连接", + "device.status.disconnected": "已断开", + "device.status.error": "错误", + "device.status.idle": "空闲", + "device.status.processing": "处理中", + "device.stopped": "处理已停止", + "device.stream_selector.hint": "选择一个源来定义此设备采集和处理的内容", + "device.stream_selector.label": "源:", + "device.stream_selector.none": "-- 未分配源 --", + "device.stream_selector.saved": "源设置已更新", + "device.stream_settings.border_width": "边框宽度(像素):", + "device.stream_settings.border_width_hint": "从屏幕边缘采样多少像素来确定 LED 颜色(1-100)", + "device.stream_settings.interpolation": "插值模式:", + "device.stream_settings.interpolation.average": "平均", + "device.stream_settings.interpolation.dominant": "主色", + "device.stream_settings.interpolation.median": "中位数", + "device.stream_settings.interpolation_hint": "如何从采样像素计算 LED 颜色", + "device.stream_settings.smoothing": "平滑:", + "device.stream_settings.smoothing_hint": "帧间时间混合(0=无,1=完全)。减少闪烁。", + "device.stream_settings.title": "源设置", + "device.tip.add": "点击此处添加新的 LED 设备", + "device.tip.brightness": "拖动调节设备亮度。更改将立即发送到设备。", + "device.tip.identity": "设备标识 — 名称、类型徽章和在线/离线状态指示。", + "device.tip.menu": "更多操作 — 复制、隐藏或删除此设备。", + "device.tip.metadata": "设备地址和固件版本。点击 URL 可打开设备的内置 Web 界面。", + "device.tip.ping": "Ping 设备 — 刷新在线状态并测量延迟。", + "device.tip.settings": "打开设备设置 — 配置名称、地址、能力和健康检查。", + "device.tip.stream_selector": "为此设备配置图片源和 LED 投影设置", + "device.tutorial.start": "开始教程", + "device.type": "设备类型:", + "device.type.adalight": "Adalight", + "device.type.adalight.desc": "Arduino串口LED协议", + "device.type.ambiled": "AmbiLED", + "device.type.ambiled.desc": "AmbiLED串口协议", + "device.type.ble": "BLE LED 控制器", + "device.type.ble.desc": "Bluetooth LE 灯带:SP110E、Triones、Zengge、Govee(整条灯带同色)", + "device.type.chroma": "Razer Chroma", + "device.type.chroma.desc": "Razer peripherals via Chroma SDK", + "device.type.ddp": "DDP", + "device.type.ddp.desc": "直接UDP像素推送 (Pixelblaze、ESPixelStick、Falcon)", + "device.type.dmx": "DMX", + "device.type.dmx.desc": "Art-Net / sACN (E1.31) 舞台灯光", + "device.type.espnow": "ESP-NOW", + "device.type.espnow.desc": "Ultra-low-latency via ESP32 gateway", + "device.type.gamesense": "SteelSeries", + "device.type.gamesense.desc": "SteelSeries peripherals via GameSense", + "device.type.govee": "Govee", + "device.type.govee.desc": "通过 LAN API 连接 Govee Wi-Fi 灯泡/氛围套件", + "device.type.group": "设备组", + "device.type.group.desc": "将多个设备组合为一个虚拟设备", + "device.type.hint": "选择 LED 控制器的类型", + "device.type.hue": "Philips Hue", + "device.type.hue.desc": "Hue Entertainment API streaming", + "device.type.lifx": "LIFX", + "device.type.lifx.desc": "通过局域网连接 LIFX 智能灯泡/灯带", + "device.type.mock": "Mock", + "device.type.mock.desc": "用于测试的虚拟设备", + "device.type.mqtt": "MQTT", + "device.type.mqtt.desc": "通过MQTT代理发布LED数据", + "device.type.nanoleaf": "Nanoleaf", + "device.type.nanoleaf.desc": "Nanoleaf Light Panels / Canvas / Shapes / Lines / Elements(HTTP REST,需要配对)", + "device.type.opc": "OPC", + "device.type.opc.desc": "Open Pixel Control (Fadecandy、xLights、爱好者驱动)", + "device.type.openrgb": "OpenRGB", + "device.type.openrgb.desc": "通过OpenRGB控制RGB外设", + "device.type.spi": "SPI Direct", + "device.type.spi.desc": "Raspberry Pi GPIO/SPI LED strips", + "device.type.usbhid": "USB HID", + "device.type.usbhid.desc": "USB RGB peripherals (keyboards, mice)", + "device.type.wiz": "WiZ", + "device.type.wiz.desc": "WiZ Connected (飞利浦) UDP 局域网灯泡", + "device.type.wled": "WLED", + "device.type.wled.desc": "通过HTTP/UDP控制的WiFi LED", + "device.type.ws": "WebSocket", + "device.type.ws.desc": "通过WebSocket流式传输LED数据", + "device.type.yeelight": "Yeelight", + "device.type.yeelight.desc": "通过局域网连接小米智能灯泡/灯带(单色,由灯带颜色平均得出)", + "device.url": "地址:", + "device.url.hint": "设备的 IP 地址或主机名(例如 http://192.168.1.100)", + "device.url.placeholder": "http://192.168.1.100", + "device.usbhid.url": "VID:PID:", + "device.usbhid.url.hint": "USB Vendor:Product ID in hex (e.g. 1532:0084)", + "device.wiz.url": "IP 地址:", + "device.wiz.url.hint": "WiZ 灯泡的局域网 IP。UDP 端口 38899 为协议默认值。", + "device.wiz.url.placeholder": "192.168.1.50", + "device.wiz_min_interval": "最小更新间隔:", + "device.wiz_min_interval.hint": "客户端命令速率限制(毫秒)。UDP 即发即忘可处理快速更新;默认 50 毫秒 ≈ 20 Hz。", + "device.ws_url": "连接 URL:", + "device.ws_url.hint": "客户端连接并接收 LED 数据的 WebSocket URL", + "device.yeelight.url": "IP 地址:", + "device.yeelight.url.hint": "Yeelight 灯泡的局域网 IP。协议固定使用 TCP 端口 55443。", + "device.yeelight.url.placeholder": "192.168.1.50", + "device.yeelight_min_interval": "最小更新间隔:", + "device.yeelight_min_interval.hint": "客户端命令速率限制(毫秒)。默认 500 毫秒可使灯泡保持在约 1 cmd/sec 限制下;较低的值可能导致节流。", + "device_discovery.added": "设备添加成功", + "device_discovery.error.add_failed": "添加设备失败", + "device_discovery.error.fill_all_fields": "请填写所有字段", + "devices.add": "添加新设备", + "devices.failed": "加载设备失败", + "devices.loading": "正在加载设备...", + "devices.none": "尚未配置设备", + "devices.title": "设备", + "devices.wled_config": "WLED 配置:", + "devices.wled_link": "官方 WLED 应用", + "devices.wled_note": "使用以下方式配置您的 WLED 设备(效果、分段、颜色顺序、功率限制等):", + "devices.wled_note_or": "或内置的", + "devices.wled_note_webui": "(在浏览器中打开设备 IP 地址)。", + "devices.wled_note2": "此控制器发送像素颜色数据并控制每个设备的亮度。", + "devices.wled_webui_link": "WLED Web UI", + "displays.badge.primary": "主", + "displays.badge.secondary": "副", + "displays.failed": "加载显示器失败", + "displays.index": "显示器序号:", + "displays.information": "显示器信息", + "displays.layout": "显示器", + "displays.legend.primary": "主显示器", + "displays.legend.secondary": "副显示器", + "displays.loading": "正在加载显示器...", + "displays.none": "没有可用的显示器", + "displays.picker.adb_connect": "连接 ADB 设备", + "displays.picker.adb_connect.button": "连接", + "displays.picker.adb_connect.error": "连接设备失败", + "displays.picker.adb_connect.placeholder": "IP 地址(例如 192.168.2.201)", + "displays.picker.adb_connect.success": "设备已连接", + "displays.picker.adb_disconnect": "断开连接", + "displays.picker.click_to_select": "点击选择此显示器", + "displays.picker.eyebrow.channel": "显示器 · 布局", + "displays.picker.eyebrow.channel.device": "设备 · 列表", + "displays.picker.eyebrow.label": "来源", + "displays.picker.foot.dismiss": "关闭", + "displays.picker.foot.select": "选择显示器", + "displays.picker.foot.select.device": "选择设备", + "displays.picker.no_android": "未找到 Android 设备。请通过 USB 连接或在上方输入 IP 地址。", + "displays.picker.select": "选择显示器...", + "displays.picker.title": "选择显示器", + "displays.picker.title.accent": "显示器", + "displays.picker.title.lead": "选择", + "displays.position": "位置:", + "displays.refresh_rate": "刷新率:", + "displays.resolution": "分辨率:", + "displays.title": "可用显示器", + "donation.about_author": "作者:", + "donation.about_donate": "支持开发", + "donation.about_license": "MIT 许可证", + "donation.about_opensource": "LedGrab 是开源软件,可免费使用和修改。", + "donation.about_title": "关于 LedGrab", + "donation.dismiss": "不再显示", + "donation.later": "稍后提醒", + "donation.message": "LedGrab 是免费开源软件。如果它对您有帮助,请考虑支持开发。", + "donation.support": "支持项目", + "donation.view_source": "查看源代码", + "error.network": "网络错误", + "error.unauthorized": "未授权 - 请先登录", + "error.unknown": "发生错误", + "filters.add": "添加滤镜", + "filters.auto_crop": "自动裁剪", + "filters.auto_crop.desc": "移除信箱式内容的黑边", + "filters.brightness": "亮度", + "filters.brightness.desc": "调整整体图像亮度", + "filters.color_correction": "色彩校正", + "filters.color_correction.desc": "白平衡和色温调整", + "filters.css_filter_template": "色带滤镜模板", + "filters.css_filter_template.desc": "嵌入另一个色带处理模板", + "filters.downscaler": "缩小", + "filters.downscaler.desc": "降低分辨率以加快处理", + "filters.drag_to_reorder": "拖动以重新排序", + "filters.empty": "尚未添加滤镜。使用下方选择器添加滤镜。", + "filters.filter_template": "滤镜模板", + "filters.filter_template.desc": "嵌入另一个处理模板", + "filters.flip": "翻转", + "filters.flip.desc": "水平或垂直镜像翻转", + "filters.frame_interpolation": "帧插值", + "filters.frame_interpolation.desc": "帧间混合以获得更平滑的输出", + "filters.gamma": "伽马", + "filters.gamma.desc": "非线性亮度曲线校正", + "filters.noise_gate": "噪声门", + "filters.noise_gate.desc": "抑制低于阈值的细微色彩变化", + "filters.palette_quantization": "调色板量化", + "filters.palette_quantization.desc": "将颜色减少到有限调色板", + "filters.pixelate": "像素化", + "filters.pixelate.desc": "马赛克式块平均", + "filters.remove": "移除", + "filters.reverse": "反转", + "filters.reverse.desc": "反转色带中的LED顺序", + "filters.saturation": "饱和度", + "filters.saturation.desc": "增强或降低色彩强度", + "filters.select_type": "选择滤镜类型...", + "game_integration.adapter": "适配器", + "game_integration.adapter_config": "适配器配置", + "game_integration.adapter_type": "游戏/适配器:", + "game_integration.adapter_type.hint": "选择此集成的游戏或适配器类型", + "game_integration.add": "添加游戏集成", + "game_integration.auto_setup": "自动配置", + "game_integration.auto_setup.failed": "自动配置失败", + "game_integration.auto_setup.game_not_found": "未找到游戏安装", + "game_integration.auto_setup.not_supported": "此适配器不支持自动配置", + "game_integration.auto_setup.save_first": "请先保存集成,然后再运行自动配置", + "game_integration.auto_setup.success": "配置文件写入成功", + "game_integration.auto_setup.token_generated": "授权令牌已自动生成", + "game_integration.confirm_delete": "删除此游戏集成?", + "game_integration.created": "游戏集成已创建", + "game_integration.deleted": "游戏集成已删除", + "game_integration.description": "描述:", + "game_integration.description.hint": "可选描述此集成的用途", + "game_integration.edit": "编辑游戏集成", + "game_integration.enabled": "启用", + "game_integration.enabled.hint": "已禁用的集成将停止轮询和发出事件", + "game_integration.error.delete_failed": "删除游戏集成失败", + "game_integration.error.name_required": "名称不能为空", + "game_integration.error.save_failed": "保存游戏集成失败", + "game_integration.error.save_first": "请先保存集成以测试连接", + "game_integration.event_mappings": "事件映射", + "game_integration.event_mappings.hint": "将游戏事件映射到 LED 效果。每种事件类型可触发不同的视觉效果。", + "game_integration.events.monitor": "事件监控", + "game_integration.events.title": "实时事件", + "game_integration.events.waiting": "等待事件...", + "game_integration.mapping.add": "+ 添加映射", + "game_integration.mapping.color": "颜色", + "game_integration.mapping.duration": "持续时间 (毫秒)", + "game_integration.mapping.effect_type": "效果", + "game_integration.mapping.event_type": "事件", + "game_integration.mapping.intensity": "强度", + "game_integration.mapping.priority": "优先级", + "game_integration.mapping.select_preset": "加载预设...", + "game_integration.mappings": "映射", + "game_integration.name": "名称:", + "game_integration.name.hint": "为此游戏集成提供一个描述性名称", + "game_integration.no_config": "此适配器无需配置。", + "game_integration.preset.fps_combat": "FPS 战斗", + "game_integration.preset.moba_health": "MOBA 生命值", + "game_integration.preset.select": "加载预设...", + "game_integration.section_title": "游戏集成", + "game_integration.setup_instructions": "设置说明", + "game_integration.setup_instructions.hint": "按照以下步骤配置您的游戏向此集成发送数据", + "game_integration.status": "状态", + "game_integration.status.active": "活跃", + "game_integration.status.inactive": "未激活", + "game_integration.test.button": "测试连接", + "game_integration.test.error": "连接错误", + "game_integration.test.success": "连接成功!已收到事件。", + "game_integration.test.timeout": "在超时期间内未收到事件。", + "game_integration.test.waiting": "等待游戏事件...", + "game_integration.updated": "游戏集成已更新", + "gradient.error.delete_failed": "删除渐变失败", + "gradient.error.save_failed": "保存渐变失败", + "graph.action.connect": "连接", + "graph.action.disconnect": "断开连接", + "graph.action.move": "移动节点", + "graph.action.rewire": "重新连接槽位", + "graph.add_entity": "添加实体", + "graph.bulk_delete_confirm": "删除 {count} 个选中的实体?", + "graph.choose_connection": "选择连接", + "graph.color_picker": "节点颜色", + "graph.connection_failed": "更新连接失败", + "graph.connection_removed": "连接已移除", + "graph.connection_updated": "连接已更新", + "graph.create_and_connect": "创建并连接…", + "graph.delete_with_dependents_confirm": "此实体被 {count} 个其他实体引用:{names}。删除并断开这些连接?", + "graph.disconnect": "断开连接", + "graph.disconnect_failed": "断开连接失败", + "graph.duplicate": "复制所选", + "graph.duplicate_done": "已复制 {count} 个源", + "graph.duplicate_done_warn": "已复制 {count} 个源 — 部分引用无法重新映射", + "graph.duplicate_failed": "复制所选失败", + "graph.duplicate_none": "请选择一个或多个节点以复制", + "graph.duplicate_none_eligible": "所选内容中没有可复制的项(仅值源和色带源)", + "graph.empty": "暂无实体", + "graph.empty.hint": "创建设备、源和目标后即可在此查看。", + "graph.export": "导出图谱 (JSON)", + "graph.export_done": "图谱已导出", + "graph.export_failed": "导出图谱失败", + "graph.filter": "筛选节点", + "graph.filter_clear": "清除筛选", + "graph.filter_group.audio": "音频", + "graph.filter_group.capture": "捕获", + "graph.filter_group.other": "其他", + "graph.filter_group.strip": "色带", + "graph.filter_group.targets": "目标", + "graph.filter_placeholder": "按名称筛选...", + "graph.filter_running": "运行中", + "graph.filter_stopped": "已停止", + "graph.filter_types": "类型", + "graph.fit_all": "显示所有节点", + "graph.fullscreen": "切换全屏", + "graph.help.add": "添加实体", + "graph.help.click": "单击", + "graph.help.click_desc": "选择节点", + "graph.help.dblclick": "双击", + "graph.help.dblclick_desc": "缩放到节点", + "graph.help.delete": "删除 / 断开", + "graph.help.deselect": "取消选择", + "graph.help.drag_node": "拖拽节点", + "graph.help.drag_node_desc": "重新定位", + "graph.help.drag_port": "拖拽端口", + "graph.help.drag_port_desc": "连接实体", + "graph.help.filter": "筛选", + "graph.help.fullscreen": "全屏", + "graph.help.navigate": "节点导航", + "graph.help.redo": "重做", + "graph.help.right_click": "右键边线", + "graph.help.right_click_desc": "断开连接", + "graph.help.search": "搜索", + "graph.help.select_all": "全选", + "graph.help.shift_click": "Shift+单击", + "graph.help.shift_click_desc": "多选", + "graph.help.shift_drag": "Shift+拖拽", + "graph.help.shift_drag_desc": "框选", + "graph.help.shortcuts": "快捷键", + "graph.help.undo": "撤销", + "graph.help_title": "键盘快捷键", + "graph.issue.broken_ref": "无效引用:{field}", + "graph.issue.cycle": "属于依赖循环", + "graph.issues": "问题", + "graph.issues_none": "未发现问题", + "graph.legend": "图例", + "graph.minimap": "小地图", + "graph.no_compatible_connection": "这些实体之间没有兼容的连接", + "graph.nothing_to_redo": "没有可重做的操作", + "graph.nothing_to_undo": "没有可撤销的操作", + "graph.redone": "已重做", + "graph.relayout": "重新布局", + "graph.relayout_confirm": "重置所有手动节点位置并重新布局图表?", + "graph.replace_connection_confirm": "替换现有连接?", + "graph.rewire": "重新连接…", + "graph.rewire_choose_source": "选择新的来源", + "graph.search": "搜索节点", + "graph.search_placeholder": "搜索实体...", + "graph.title": "图表", + "graph.tooltip.errors": "错误", + "graph.tooltip.fps": "帧率", + "graph.tooltip.uptime": "运行时间", + "graph.undone": "已撤销", + "graph.zoom_in": "放大", + "graph.zoom_out": "缩小", + "ha_light.add": "添加 HA 灯光目标", + "ha_light.button.turn_off": "关闭灯具", + "ha_light.color_source": "颜色源:", + "ha_light.color_source.color_vs": "颜色值源", + "ha_light.color_source.css": "颜色条", + "ha_light.color_source.hint": "选择颜色条源(每个灯具的 LED 范围)或颜色值源(一种颜色广播到所有灯具)。", + "ha_light.color_tolerance": "色彩容差:", + "ha_light.color_tolerance.hint": "当RGB差异低于此阈值时跳过颜色更新,减少HA流量。", + "ha_light.connection_metric": "Home Assistant 连接", + "ha_light.connection_tooltip": "HA 连接", + "ha_light.created": "HA 灯光目标已创建", + "ha_light.css_source": "颜色条源:", + "ha_light.description": "描述(可选):", + "ha_light.edit": "编辑 HA 灯光目标", + "ha_light.error.color_source_required": "广播单一颜色时必须选择颜色值源", + "ha_light.error.ha_source_required": "需要 HA 连接", + "ha_light.error.name_required": "需要名称", + "ha_light.ha_source": "HA 连接:", + "ha_light.lights.one": "{count} 盏灯", + "ha_light.lights.other": "{count} 盏灯", + "ha_light.mapping.brightness": "亮度比例:", + "ha_light.mapping.entity_id": "实体 ID:", + "ha_light.mapping.led_end": "LED 结束(-1=最后):", + "ha_light.mapping.led_start": "LED 开始:", + "ha_light.mapping.search_entity": "搜索灯光实体...", + "ha_light.mapping.select_entity": "选择一个灯光实体...", + "ha_light.mapping.unassigned": "— 未选择实体", + "ha_light.mappings": "灯光映射:", + "ha_light.mappings.add": "添加映射", + "ha_light.mappings.color_vs_hint": "所有列出的灯具都将接收来自所选颜色值源的相同颜色。", + "ha_light.mappings.hint": "将 LED 范围映射到 HA 灯光实体。每个映射将 LED 段平均为单一颜色。", + "ha_light.min_brightness_threshold": "最低亮度阈值:", + "ha_light.min_brightness_threshold.hint": "有效输出亮度低于此值时完全关灯(0=禁用)。", + "ha_light.name": "名称:", + "ha_light.name.placeholder": "客厅灯光", + "ha_light.section.targets": "灯光目标", + "ha_light.section.title": "Home Assistant", + "ha_light.stop_action": "停止时:", + "ha_light.stop_action.hint": "此目标停止流式传输时对映射的灯执行的操作。", + "ha_light.stop_action.none": "无", + "ha_light.stop_action.none.desc": "保持灯的当前状态", + "ha_light.stop_action.restore": "恢复", + "ha_light.stop_action.restore.desc": "恢复到启动时捕获的状态", + "ha_light.stop_action.turn_off": "关闭", + "ha_light.stop_action.turn_off.desc": "关闭所有映射的灯", + "ha_light.transition": "过渡:", + "ha_light.transition.hint": "颜色之间的平滑过渡时长(HA transition 参数)。", + "ha_light.turn_off.failed": "关闭灯具失败", + "ha_light.turn_off.success": "灯具已关闭", + "ha_light.update_rate": "更新频率:", + "ha_light.update_rate.hint": "向 HA 灯光发送颜色更新的频率(0.5-5.0 Hz)。较低值对 HA 性能更安全。", + "ha_light.updated": "HA 灯光目标已更新", + "ha_source.use_ssl.hint": "为 Home Assistant 启用 HTTPS / wss 连接", + "http_endpoint.add": "添加 HTTP 端点", + "http_endpoint.auth.set": "认证", + "http_endpoint.auth_token": "认证令牌(可选):", + "http_endpoint.auth_token.edit_hint": "留空以保留当前令牌", + "http_endpoint.auth_token.hint": "作为 \u0027Authorization: Bearer \u003ctoken\u003e\u0027 发送。在请求头中添加自定义 Authorization 可覆盖。", + "http_endpoint.auth_token.reveal": "显示 / 隐藏令牌", + "http_endpoint.created": "已创建 HTTP 端点", + "http_endpoint.delete.confirm": "删除此 HTTP 端点?引用它的值源需要重新指向其他端点。", + "http_endpoint.deleted": "已删除 HTTP 端点", + "http_endpoint.description": "描述(可选):", + "http_endpoint.edit": "编辑 HTTP 端点", + "http_endpoint.error.load": "加载 HTTP 端点失败", + "http_endpoint.error.name_required": "需要名称", + "http_endpoint.error.timeout_invalid": "超时必须为正数", + "http_endpoint.error.url_required": "需要 URL", + "http_endpoint.group.title": "HTTP 端点", + "http_endpoint.headers": "自定义请求头:", + "http_endpoint.headers.add": "添加请求头", + "http_endpoint.headers.count": "{n} 个请求头", + "http_endpoint.headers.empty": "没有自定义请求头 — 将发送默认值。", + "http_endpoint.headers.hint": "可选的请求头(如 X-API-Key、Accept)。", + "http_endpoint.headers.name_placeholder": "请求头名称", + "http_endpoint.headers.value_placeholder": "值", + "http_endpoint.method": "方法:", + "http_endpoint.method.get.desc": "获取响应体。", + "http_endpoint.method.head.desc": "仅返回状态码,不返回响应体。适合健康检查。", + "http_endpoint.name": "名称:", + "http_endpoint.name.hint": "此端点的描述性名称", + "http_endpoint.name.placeholder": "Plex 正在播放", + "http_endpoint.section.headers": "请求头", + "http_endpoint.section.request": "请求", + "http_endpoint.test": "测试请求", + "http_endpoint.test.body.json": "JSON 主体", + "http_endpoint.test.body.text": "响应主体", + "http_endpoint.test.failed": "失败", + "http_endpoint.test.pending": "测试中…", + "http_endpoint.test.success": "成功", + "http_endpoint.timeout": "超时(秒):", + "http_endpoint.timeout.hint": "单次请求的最长等待秒数。", + "http_endpoint.updated": "已更新 HTTP 端点", + "http_endpoint.url": "URL:", + "http_endpoint.url.hint": "要轮询的完整 http(s) URL,允许使用本地地址。", + "integrations.title": "集成", + "locale.change": "切换语言", + "modal.discard_changes": "有未保存的更改。是否放弃?", + "notifications.bulk.body": "同时发生 {count} 个设备事件", + "notifications.bulk.title": "多个设备状态变化", + "notifications.device_discovered.body": "{device} 出现在网络中", + "notifications.device_discovered.title": "发现新设备", + "notifications.device_lost.body": "{device} 已消失", + "notifications.device_lost.title": "设备消失", + "notifications.device_offline.body": "{device} 没有响应", + "notifications.device_offline.title": "设备离线", + "notifications.device_online.body": "{device} 已重新上线", + "notifications.device_online.title": "设备已上线", + "notifications.test.body": "通知功能已正常工作。", + "notifications.test.title": "LedGrab 测试通知", + "notifications.unknown_device": "未知设备", + "overlay.button.hide": "隐藏叠加层可视化", + "overlay.button.show": "显示叠加层可视化", + "overlay.error.start": "启动叠加层失败", + "overlay.error.stop": "停止叠加层失败", + "overlay.started": "叠加层可视化已启动", + "overlay.stopped": "叠加层可视化已停止", + "overlay.toggle": "切换屏幕叠加层", + "pairing.cancel": "取消", + "pairing.close": "关闭", + "pairing.failed": "配对失败:{detail}", + "pairing.failed_prefix": "配对失败:", + "pairing.instructions.default": "请按住设备上的配对按钮,然后点击「开始」。设备通常会打开 30 秒的配对窗口——请准备好快速操作。", + "pairing.not_ready": "设备未响应。请按下设备上的配对按钮后重试。", + "pairing.pairing": "配对中…", + "pairing.retry": "重试", + "pairing.start": "开始配对", + "pairing.success": "配对成功", + "pairing.title": "配对设备", + "palette.search": "搜索…", + "patch.checking": "检测中", + "patch.disconnected": "已断开", + "patch.not_configured": "未配置", + "patch.offline": "离线", + "patch.online": "在线", + "patch.patched": "已连接", + "patch.paused": "已暂停", + "patch.pipeline": "管线", + "patch.polling": "轮询中", + "patch.preset": "预设", + "patch.ready": "就绪", + "patch.source": "源", + "patch.standby": "待机", + "patch.streaming": "传输中", + "patch.strip": "色带", + "patch.template": "模板", + "patch.ticking": "运行中", + "patch.unreachable": "无法访问", + "patch.value": "值", + "pattern.add": "添加图案模板", + "pattern.capture_bg": "采集背景", + "pattern.created": "图案模板创建成功", + "pattern.delete.confirm": "确定要删除此图案模板吗?", + "pattern.delete.referenced": "无法删除:此模板正被目标引用", + "pattern.delete_selected": "删除选中", + "pattern.deleted": "图案模板删除成功", + "pattern.description.hint": "关于此图案使用位置或方式的可选说明", + "pattern.description_label": "描述(可选):", + "pattern.description_placeholder": "描述此图案...", + "pattern.edit": "编辑图案模板", + "pattern.error.capture_bg_failed": "捕获背景失败", + "pattern.error.clone_failed": "克隆图案模板失败", + "pattern.error.delete_failed": "删除图案模板失败", + "pattern.error.editor_open_failed": "打开图案模板编辑器失败", + "pattern.error.required": "请填写所有必填项", + "pattern.error.save_failed": "保存图案模板失败", + "pattern.name": "模板名称:", + "pattern.name.hint": "此矩形布局的描述性名称", + "pattern.name.placeholder": "我的图案模板", + "pattern.rect.add": "添加矩形", + "pattern.rect.empty": "尚未定义矩形。请至少添加一个矩形。", + "pattern.rect.height": "高", + "pattern.rect.name": "名称", + "pattern.rect.remove": "移除", + "pattern.rect.width": "宽", + "pattern.rect.x": "X", + "pattern.rect.y": "Y", + "pattern.rectangles": "矩形", + "pattern.rectangles.hint": "用精确坐标(0.0 到 1.0)微调矩形位置和大小", + "pattern.source_for_bg": "背景源:", + "pattern.source_for_bg.none": "-- 选择源 --", + "pattern.updated": "图案模板更新成功", + "pattern.visual_editor": "可视编辑器", + "pattern.visual_editor.hint": "点击 + 按钮添加矩形。拖动边缘调整大小,拖动内部移动位置。", + "perf.all_online": "全部在线", + "perf.captures_count.one": "{count} 个源", + "perf.captures_count.other": "{count} 个源", + "perf.idle": "空闲", + "perf.max_ms": "最大 {ms}毫秒", + "perf.no_captures": "无源", + "perf.no_devices": "无设备", + "perf.offline": "离线", + "perf.offline_count": "{count} 离线", + "perf.online": "在线", + "perf.online_count": "{count} 在线", + "perf.ratio_of_requested": "请求的 {percent}% · {captures}", + "perf.skipped_per_sec": "跳过 {rate}/秒", + "perf.targets_count.one": "{count} 个目标", + "perf.targets_count.other": "{count} 个目标", + "perf.tip.ago": "−{seconds} 秒", + "perf.tip.now": "现在", + "perf.total_bytes": "共 {bytes}", + "perf.total_count": "共 {count}", + "picture_source.type.video": "视频", + "picture_source.type.video.desc": "从上传的视频素材中流式传输帧", + "picture_source.video.end_time": "结束时间(秒):", + "picture_source.video.loop": "循环:", + "picture_source.video.resolution_limit": "最大宽度(像素):", + "picture_source.video.resolution_limit.hint": "解码时缩小视频以提高性能", + "picture_source.video.speed": "播放速度:", + "picture_source.video.start_time": "开始时间(秒):", + "playlists.action.start": "开始", + "playlists.action.stop": "停止", + "playlists.add": "新建播放列表", + "playlists.chip.loop": "循环", + "playlists.chip.shuffle": "随机", + "playlists.created": "播放列表已创建", + "playlists.delete_confirm": "删除播放列表“{name}”?", + "playlists.deleted": "播放列表已删除", + "playlists.description": "描述:", + "playlists.description.hint": "此播放列表的可选描述", + "playlists.edit": "编辑播放列表", + "playlists.error.delete_failed": "删除播放列表失败", + "playlists.error.name_required": "需要名称", + "playlists.error.no_presets": "请先创建场景预设", + "playlists.error.save_failed": "保存播放列表失败", + "playlists.error.start_failed": "启动播放列表失败", + "playlists.error.stop_failed": "停止播放列表失败", + "playlists.item.duration": "秒", + "playlists.item.missing": "缺失", + "playlists.item.move_down": "下移", + "playlists.item.move_up": "上移", + "playlists.items.empty": "还没有场景 — 在下方添加", + "playlists.items.search_placeholder": "搜索场景...", + "playlists.loop": "循环:", + "playlists.loop.hint": "最后一个场景结束后从第一个重新开始;关闭则播放一遍后停止", + "playlists.name": "名称:", + "playlists.name.placeholder": "我的播放列表", + "playlists.scene_many": "个场景", + "playlists.scene_one": "个场景", + "playlists.scenes": "场景:", + "playlists.scenes.add": "添加场景", + "playlists.scenes.hint": "此播放列表循环的场景预设,每个按各自的时长保持", + "playlists.scenes_count": "个场景", + "playlists.section.playback": "播放", + "playlists.shuffle": "随机:", + "playlists.shuffle.hint": "每个循环开始时随机打乱场景顺序", + "playlists.start": "开始播放列表", + "playlists.started": "播放列表已开始", + "playlists.status.playing": "播放中", + "playlists.status.stopped": "已停止", + "playlists.stop": "停止播放列表", + "playlists.stopped": "播放列表已停止", + "playlists.title": "播放列表", + "playlists.updated": "播放列表已更新", + "postprocessing.add": "添加滤镜模板", + "postprocessing.config.show": "显示设置", + "postprocessing.created": "模板创建成功", + "postprocessing.delete.confirm": "确定要删除此滤镜模板吗?", + "postprocessing.deleted": "模板删除成功", + "postprocessing.description": "处理模板定义图像滤镜和色彩校正。将它们分配给处理图片源以实现跨设备的一致后处理。", + "postprocessing.description_label": "描述(可选):", + "postprocessing.description_placeholder": "描述此模板...", + "postprocessing.edit": "编辑滤镜模板", + "postprocessing.error.delete": "删除处理模板失败", + "postprocessing.error.load": "加载处理模板失败", + "postprocessing.error.required": "请填写所有必填项", + "postprocessing.name": "模板名称:", + "postprocessing.name.placeholder": "我的滤镜模板", + "postprocessing.test.error.failed": "处理模板测试失败", + "postprocessing.test.error.no_stream": "请选择一个源", + "postprocessing.test.running": "正在测试处理模板...", + "postprocessing.test.source_stream": "源:", + "postprocessing.test.title": "测试滤镜模板", + "postprocessing.title": "滤镜模板", + "postprocessing.updated": "模板更新成功", + "scene_preset.activated": "预设已激活", + "scene_preset.used_by": "被 %d 个自动化使用", + "scenes.action.activate": "激活", + "scenes.action.recapture": "重新捕获", + "scenes.activate": "激活场景", + "scenes.activated": "场景已激活", + "scenes.activated_partial": "场景部分激活", + "scenes.add": "捕获场景", + "scenes.capture": "捕获", + "scenes.captured": "场景已捕获", + "scenes.cloned": "场景已克隆", + "scenes.delete": "删除场景", + "scenes.delete_confirm": "删除场景\"{name}\"?", + "scenes.deleted": "场景已删除", + "scenes.description": "描述:", + "scenes.description.hint": "此场景功能的可选描述", + "scenes.edit": "编辑场景", + "scenes.error.activate_failed": "激活场景失败", + "scenes.error.clone_failed": "克隆场景失败", + "scenes.error.delete_failed": "删除场景失败", + "scenes.error.name_required": "名称为必填项", + "scenes.error.recapture_failed": "重新捕获场景失败", + "scenes.error.save_failed": "保存场景失败", + "scenes.errors": "错误", + "scenes.name": "名称:", + "scenes.name.hint": "此场景预设的描述性名称", + "scenes.name.placeholder": "我的场景", + "scenes.recapture": "重新捕获当前状态", + "scenes.recapture_confirm": "将当前状态重新捕获到\"{name}\"中?", + "scenes.recaptured": "场景已重新捕获", + "scenes.status.preset": "预设", + "scenes.targets": "目标:", + "scenes.targets.add": "添加目标", + "scenes.targets.empty": "未选择目标", + "scenes.targets.hint": "选择要包含在此场景快照中的目标", + "scenes.targets.search_placeholder": "搜索目标...", + "scenes.targets_count": "目标", + "scenes.title": "场景", + "scenes.updated": "场景已更新", + "search.action.activate": "激活", + "search.action.disable": "禁用", + "search.action.enable": "启用", + "search.action.start": "启动", + "search.action.stop": "停止", + "search.footer": "↑↓ 导航 · Enter 选择 · Esc 关闭", + "search.group.actions": "操作", + "search.group.audio": "音频源", + "search.group.automations": "自动化", + "search.group.capture_templates": "采集模板", + "search.group.cspt": "色带处理模板", + "search.group.css": "色带源", + "search.group.devices": "设备", + "search.group.kc_targets": "关键颜色目标", + "search.group.pattern_templates": "图案模板", + "search.group.pp_templates": "后处理模板", + "search.group.scenes": "场景预设", + "search.group.streams": "图片流", + "search.group.sync_clocks": "同步时钟", + "search.group.targets": "LED 目标", + "search.group.value": "值源", + "search.loading": "加载中...", + "search.no_results": "未找到结果", + "search.open": "搜索 (Ctrl+K)", + "search.placeholder": "搜索实体... (Ctrl+K)", + "section.collapse_all": "全部折叠", + "section.empty.assets": "暂无资源。点击 + 上传一个。", + "section.empty.audio_processing_templates": "暂无音频处理模板。点击 + 创建一个。", + "section.empty.audio_sources": "暂无音频源。点击 + 添加。", + "section.empty.audio_templates": "暂无音频模板。点击 + 添加。", + "section.empty.automations": "暂无自动化。点击 + 添加。", + "section.empty.capture_templates": "暂无捕获模板。点击 + 添加。", + "section.empty.color_strips": "暂无色带。点击 + 添加。", + "section.empty.cspt": "暂无 CSS 处理模板。点击 + 添加。", + "section.empty.devices": "暂无设备。点击 + 添加。", + "section.empty.game_integrations": "暂无游戏集成。点击 + 创建。", + "section.empty.http_endpoints": "暂无 HTTP 端点。点击 + 添加一个。", + "section.empty.kc_targets": "暂无键色目标。点击 + 添加。", + "section.empty.pattern_templates": "暂无图案模板。点击 + 添加。", + "section.empty.picture_sources": "暂无源。点击 + 添加。", + "section.empty.playlists": "暂无播放列表。点击 + 添加。", + "section.empty.pp_templates": "暂无后处理模板。点击 + 添加。", + "section.empty.scenes": "暂无场景预设。点击 + 添加。", + "section.empty.sync_clocks": "暂无同步时钟。点击 + 添加。", + "section.empty.targets": "暂无 LED 目标。点击 + 添加。", + "section.empty.value_sources": "暂无值源。点击 + 添加。", + "section.empty.weather_sources": "暂无天气源。点击+添加。", + "section.expand_all": "全部展开", + "section.filter.placeholder": "筛选...", + "section.filter.reset": "清除筛选", + "server.healthy": "服务器在线", + "server.offline": "服务器离线", + "settings.activity_log.clear.auth_required": "清除活动日志需要身份验证", + "settings.activity_log.clear.button": "清除日志", + "settings.activity_log.clear.confirm": "清除所有活动日志条目?此操作无法撤销。", + "settings.activity_log.clear.error": "清除活动日志失败", + "settings.activity_log.clear.hint": "永久删除所有活动日志条目。此操作会被记录——系统将记录谁在何时清除了日志。", + "settings.activity_log.clear.label": "清除所有条目", + "settings.activity_log.clear.success": "活动日志已清除", + "settings.activity_log.distinction_note": "这是持久审计日志——记录每次实体变更、认证事件和系统操作的结构化记录。它与临时调试日志查看器(实时服务器日志尾流,断开连接时重置)不同。", + "settings.activity_log.enabled.hint": "禁用时不记录新的审计条目,现有条目保留。", + "settings.activity_log.enabled.label": "启用活动日志记录", + "settings.activity_log.error.max_days_range": "最大天数必须在0到3650之间", + "settings.activity_log.error.max_entries_range": "最大条目数必须在0到10,000,000之间", + "settings.activity_log.export.hint": "将完整活动日志下载为文件。", + "settings.activity_log.export.view_tab_hint": "要使用筛选条件导出,请使用", + "settings.activity_log.export_csv.button": "CSV", + "settings.activity_log.export_csv.label": "导出为CSV", + "settings.activity_log.export_json.button": "JSON", + "settings.activity_log.export_json.label": "导出为JSON", + "settings.activity_log.max_days.hint": "超过指定天数的条目将自动删除。设为0禁用基于时间的删除。", + "settings.activity_log.max_days.label": "最大保留天数", + "settings.activity_log.max_entries.hint": "保留的最大条目数。达到限制时,最旧的条目将被删除。设为0表示无限制。", + "settings.activity_log.max_entries.label": "最大条目数", + "settings.activity_log.open_activity_tab": "活动选项卡", + "settings.activity_log.open_log_viewer": "调试日志查看器", + "settings.activity_log.save": "保存设置", + "settings.activity_log.save_error": "保存设置失败", + "settings.activity_log.saved": "活动日志设置已保存", + "settings.activity_log.section.clear": "清除日志", + "settings.activity_log.section.export": "导出", + "settings.activity_log.section.retention": "保留设置", + "settings.api_keys.empty": "未配置 API 密钥", + "settings.api_keys.hint": "API 密钥在服务器配置文件 (config.yaml) 中定义。编辑文件并重启服务器以应用更改。", + "settings.api_keys.label": "API 密钥", + "settings.api_keys.load_error": "加载 API 密钥失败", + "settings.api_keys.meta.many": "个密钥", + "settings.api_keys.meta.one": "个密钥", + "settings.api_keys.read_only": "只读", + "settings.auto_backup.backup_created": "备份已创建", + "settings.auto_backup.backup_error": "备份失败", + "settings.auto_backup.backup_now": "立即备份", + "settings.auto_backup.enable": "启用自动备份", + "settings.auto_backup.hint": "自动定期创建所有配置的备份。当达到最大数量时,旧备份会被自动清理。", + "settings.auto_backup.interval_label": "间隔", + "settings.auto_backup.label": "自动备份", + "settings.auto_backup.last_backup": "上次备份", + "settings.auto_backup.max_label": "最大备份数", + "settings.auto_backup.never": "从未", + "settings.auto_backup.pill.running": "运行中", + "settings.auto_backup.save": "保存设置", + "settings.auto_backup.save_error": "保存自动备份设置失败", + "settings.auto_backup.saved": "自动备份设置已保存", + "settings.auto_shutdown": "自动恢复:", + "settings.auto_shutdown.hint": "当目标停止或服务器关闭时恢复设备到空闲状态", + "settings.backup.button": "下载", + "settings.backup.error": "备份下载失败", + "settings.backup.hint": "将所有配置(设备、目标、流、模板、自动化)下载为单个 JSON 文件。", + "settings.backup.label": "备份配置", + "settings.backup.success": "备份下载成功", + "settings.brightness": "亮度:", + "settings.brightness.hint": "此设备的全局亮度(0-100%)", + "settings.button.cancel": "取消", + "settings.button.close": "关闭", + "settings.button.save": "保存更改", + "settings.capture.failed": "保存采集设置失败", + "settings.capture.saved": "采集设置已更新", + "settings.capture.title": "采集设置", + "settings.capture_template": "引擎模板:", + "settings.capture_template.hint": "此设备的屏幕采集引擎和配置", + "settings.daylight_timezone.hint": "所有“实时”日光周期读取挂钟时间所用的 IANA 时区。留空(默认)则使用服务器的系统时区。", + "settings.daylight_timezone.label": "日光周期时区", + "settings.daylight_timezone.save_error": "保存日光周期时区失败", + "settings.daylight_timezone.saved": "日光周期时区已保存", + "settings.display_index": "显示器:", + "settings.display_index.hint": "为此设备采集哪个屏幕", + "settings.external_url.hint": "设置后,此基础 URL 将用于 webhook 链接和其他用户可见的链接,代替自动检测的本地 IP。示例:https://myserver.example.com:8080", + "settings.external_url.label": "外部 URL", + "settings.external_url.placeholder": "https://myserver.example.com:8080", + "settings.external_url.save": "保存", + "settings.external_url.save_error": "保存外部 URL 失败", + "settings.external_url.saved": "外部 URL 已保存", + "settings.failed": "保存设置失败", + "settings.fps": "目标 FPS:", + "settings.fps.hint": "目标帧率(10-90)", + "settings.general.title": "常规设置", + "settings.health_interval": "健康检查间隔(秒):", + "settings.health_interval.hint": "检查设备状态的频率(5-600秒)", + "settings.log_level.desc.critical": "仅显示致命错误", + "settings.log_level.desc.debug": "详细开发输出", + "settings.log_level.desc.error": "仅显示错误", + "settings.log_level.desc.info": "正常运行消息", + "settings.log_level.desc.warning": "潜在问题", + "settings.log_level.hint": "实时更改服务器日志详细程度。DEBUG 显示最多细节;CRITICAL 仅显示致命错误。", + "settings.log_level.label": "日志级别", + "settings.log_level.save": "应用", + "settings.log_level.save_error": "更改日志级别失败", + "settings.log_level.saved": "日志级别已更改", + "settings.logs.clear": "清除", + "settings.logs.connect": "连接", + "settings.logs.disconnect": "断开", + "settings.logs.empty.sub": "连接 WebSocket 流以开始实时跟踪。", + "settings.logs.empty.title": "等待日志", + "settings.logs.error": "日志查看器连接失败", + "settings.logs.filter.all": "所有级别", + "settings.logs.filter.all_desc": "显示所有日志消息", + "settings.logs.filter.error": "仅错误", + "settings.logs.filter.error_desc": "仅错误", + "settings.logs.filter.info": "Info+", + "settings.logs.filter.info_desc": "Info、警告和错误", + "settings.logs.filter.warning": "Warning+", + "settings.logs.filter.warning_desc": "仅警告和错误", + "settings.logs.hint": "实时查看服务器日志。使用过滤器显示所需的日志级别。", + "settings.logs.label": "服务器日志", + "settings.logs.open_viewer": "打开日志查看器", + "settings.logs.patch.connecting": "连接中", + "settings.logs.patch.error": "离线", + "settings.logs.patch.idle": "待机", + "settings.logs.patch.live": "传输中", + "settings.logs.stat.err": "错误", + "settings.logs.stat.lines": "行数", + "settings.logs.stat.warn": "警告", + "settings.logs.sub": "服务器日志的实时流,可按级别过滤。在全屏覆盖层中打开。", + "settings.mqtt.base_topic_label": "基础主题", + "settings.mqtt.client_id_label": "客户端 ID", + "settings.mqtt.enabled": "启用 MQTT", + "settings.mqtt.error_host_required": "代理主机不能为空", + "settings.mqtt.hint": "配置 MQTT 代理连接,用于自动化条件和触发器。", + "settings.mqtt.host_label": "代理主机", + "settings.mqtt.label": "MQTT", + "settings.mqtt.password_label": "密码", + "settings.mqtt.password_set_hint": "已设置密码 — 留空以保留", + "settings.mqtt.port_label": "端口", + "settings.mqtt.save": "保存 MQTT 设置", + "settings.mqtt.save_error": "保存 MQTT 设置失败", + "settings.mqtt.saved": "MQTT 设置已保存", + "settings.mqtt.username_label": "用户名", + "settings.notif_matrix.col.event": "事件", + "settings.notif_matrix.event_count": "4 个事件", + "settings.notifications.background.hint": "持续扫描局域网(mDNS)和串口以发现新的 LED 设备。关闭后将不再产生「发现/消失」事件。重启服务器后生效。", + "settings.notifications.background.label": "后台发现", + "settings.notifications.background.toggle": "启用后台发现", + "settings.notifications.channel.both.desc": "弹出提示与系统通知同时显示", + "settings.notifications.channel.both.label": "两者", + "settings.notifications.channel.none.desc": "屏蔽此事件", + "settings.notifications.channel.none.label": "关闭", + "settings.notifications.channel.os.desc": "系统通知(浏览器在后台时也能收到)", + "settings.notifications.channel.os.label": "系统", + "settings.notifications.channel.snack.desc": "页面底部的弹出提示", + "settings.notifications.channel.snack.label": "内置", + "settings.notifications.intro_hint": "选择每类事件的通知方式。「内置」在页面底部弹出提示,「系统」显示系统通知(需要浏览器授权),「两者」同时显示,「关闭」则不通知。", + "settings.notifications.intro_label": "设备事件", + "settings.notifications.permission.denied": "系统通知被拒绝 — 请在浏览器设置中修改", + "settings.notifications.permission.grant": "授权", + "settings.notifications.permission.granted": "系统通知已启用", + "settings.notifications.permission.hint": "浏览器为每个站点单独管理系统通知权限。一旦被拒绝,LedGrab 将无法再次请求 — 必须在浏览器中重置。点击地址栏中的站点图标(锁形)→ 站点设置 → 通知 → 允许,然后刷新页面。", + "settings.notifications.permission.label": "系统通知权限", + "settings.notifications.permission.pill.denied": "已阻止", + "settings.notifications.permission.pill.granted": "已授权", + "settings.notifications.permission.state.default": "尚未请求授权", + "settings.notifications.permission.state.denied": "已拒绝 — 请在浏览器设置中修改", + "settings.notifications.permission.state.granted": "已授权 — 系统通知将会显示", + "settings.notifications.row.discovered": "发现新设备", + "settings.notifications.row.lost": "已发现的设备消失", + "settings.notifications.row.offline": "设备离线", + "settings.notifications.row.online": "设备重新上线", + "settings.notifications.save_error": "保存通知偏好失败", + "settings.notifications.saved": "通知偏好已保存", + "settings.notifications.test_button": "发送测试通知", + "settings.partial.export_button": "导出", + "settings.partial.export_error": "导出失败", + "settings.partial.export_success": "导出成功", + "settings.partial.hint": "导出或导入单个实体类型。导入会替换或合并现有数据并重启服务器。", + "settings.partial.import_button": "从文件导入", + "settings.partial.import_confirm_merge": "这将合并 {store} 数据并重启服务器。继续吗?", + "settings.partial.import_confirm_replace": "这将替换所有 {store} 数据并重启服务器。继续吗?", + "settings.partial.import_error": "导入失败", + "settings.partial.import_success": "导入成功", + "settings.partial.label": "部分导出 / 导入", + "settings.partial.merge_label": "合并(添加/覆盖,保留现有)", + "settings.partial.store.audio_sources": "音频源", + "settings.partial.store.audio_templates": "音频模板", + "settings.partial.store.automations": "自动化", + "settings.partial.store.capture_templates": "捕获模板", + "settings.partial.store.color_strip_processing_templates": "CSS 处理模板", + "settings.partial.store.color_strip_sources": "色带", + "settings.partial.store.devices": "设备", + "settings.partial.store.output_targets": "LED 目标", + "settings.partial.store.pattern_templates": "图案模板", + "settings.partial.store.picture_sources": "图像源", + "settings.partial.store.postprocessing_templates": "后处理模板", + "settings.partial.store.scene_presets": "场景预设", + "settings.partial.store.sync_clocks": "同步时钟", + "settings.partial.store.value_sources": "值源", + "settings.rail.group.system": "系统", + "settings.rail.group.workspace": "工作区", + "settings.restart.button": "重启", + "settings.restart.sub": "重启 LedGrab 进程。捕获和已连接设备将暂停约 3 秒。", + "settings.restart_confirm": "重启服务器?活跃的目标将被停止。", + "settings.restart_server": "重启服务器", + "settings.restarting": "正在重启服务器...", + "settings.restore.button": "恢复", + "settings.restore.confirm": "这将替换所有配置并重启服务器。确定继续吗?", + "settings.restore.error": "恢复失败", + "settings.restore.hint": "上传之前下载的备份文件以替换所有配置。服务器将自动重启。", + "settings.restore.label": "恢复配置", + "settings.restore.restart_timeout": "服务器未响应。请手动刷新页面。", + "settings.restore.restarting": "服务器正在重启...", + "settings.restore.success": "配置已恢复", + "settings.save_bar.revert": "还原", + "settings.save_bar.save": "保存", + "settings.save_bar.unsaved": "字段未保存的更改:", + "settings.saved": "设置保存成功", + "settings.saved_backups.delete": "删除", + "settings.saved_backups.delete_confirm": "删除此备份文件?", + "settings.saved_backups.delete_error": "删除备份失败", + "settings.saved_backups.download": "下载", + "settings.saved_backups.empty": "没有已保存的备份", + "settings.saved_backups.hint": "存储在服务器上的自动备份文件。下载到本地保存,或删除以释放空间。", + "settings.saved_backups.label": "已保存的备份", + "settings.saved_backups.restore": "恢复", + "settings.saved_backups.type.auto": "自动", + "settings.saved_backups.type.manual": "手动", + "settings.section.adapter": "适配器", + "settings.section.api_keys": "身份与 API", + "settings.section.auth": "认证", + "settings.section.behavior": "行为", + "settings.section.broker": "代理服务器", + "settings.section.configure": "配置", + "settings.section.connection": "连接", + "settings.section.controls": "控制", + "settings.section.destructive": "危险操作", + "settings.section.diagnostics": "诊断", + "settings.section.engine": "引擎", + "settings.section.file": "文件", + "settings.section.filtering": "过滤", + "settings.section.filters": "过滤器", + "settings.section.gradient": "渐变", + "settings.section.hardware": "硬件", + "settings.section.history": "历史", + "settings.section.identity": "标识", + "settings.section.layout": "布局", + "settings.section.lifecycle": "生命周期", + "settings.section.line_properties": "线属性", + "settings.section.loopback": "本地访问", + "settings.section.manual": "手动", + "settings.section.mappings": "映射", + "settings.section.notes": "备注", + "settings.section.notif_channels": "通道", + "settings.section.notif_discovery": "发现", + "settings.section.notif_permission": "系统权限", + "settings.section.offsets": "偏移", + "settings.section.output": "输出", + "settings.section.preview": "预览", + "settings.section.protocol": "协议", + "settings.section.provider": "提供商", + "settings.section.refresh": "刷新", + "settings.section.restart": "重启", + "settings.section.routing": "路由", + "settings.section.server": "服务器", + "settings.section.source": "源", + "settings.section.strip": "灯带", + "settings.section.test": "测试", + "settings.section.test_setup": "测试设置", + "settings.section.timing": "时序", + "settings.section.type": "类型", + "settings.shutdown_action.hint": "服务器关闭时对 LED 目标的处理方式。「停止目标」执行正常的停止流程,启用自动恢复的设备会恢复先前状态。「无」会让灯保持显示最后一帧。", + "settings.shutdown_action.label": "关机时执行", + "settings.shutdown_action.opt.nothing": "无", + "settings.shutdown_action.opt.nothing_desc": "让灯保持最后一帧", + "settings.shutdown_action.opt.stop": "停止目标", + "settings.shutdown_action.opt.stop_desc": "执行正常停止流程(按设备应用自动恢复)", + "settings.shutdown_action.save_error": "保存关机动作失败", + "settings.shutdown_action.saved": "已保存关机动作", + "settings.tab.about": "关于", + "settings.tab.activity_log": "活动日志", + "settings.tab.appearance": "外观", + "settings.tab.backup": "备份", + "settings.tab.general": "常规", + "settings.tab.mqtt": "MQTT", + "settings.tab.notifications": "通知", + "settings.tab.updates": "更新", + "settings.title": "设置", + "settings.url.hint": "设备的 IP 地址或主机名", + "setup.copied": "已复制到剪贴板", + "setup.copy": "复制代码片段", + "setup.description": "此 LedGrab 服务器未配置 API 密钥,出于安全考虑已禁用来自网络其他设备的访问。请在运行服务器的机器上配置密钥以启用局域网访问。", + "setup.hint_openssl": "生成强密钥:Linux/macOS 使用 \u003ccode\u003eopenssl rand -hex 32\u003c/code\u003e;Windows PowerShell 使用 \u003ccode\u003e[guid]::NewGuid().ToString(\u0027N\u0027) + [guid]::NewGuid().ToString(\u0027N\u0027)\u003c/code\u003e。", + "setup.retry": "我已配置密钥 — 重试", + "setup.step1_label": "1. 在服务器所在机器上,编辑 \u003ccode\u003econfig/default_config.yaml\u003c/code\u003e:", + "setup.step2_label": "2. 重启服务器,然后刷新此页面并使用该密钥登录。", + "setup.step3_label": "或者:直接在服务器本机打开 LedGrab(回环地址),无需密钥:", + "setup.still_required": "服务器仍报告未配置 API 密钥。请确认已保存配置文件并重启服务器。", + "setup.title": "服务器需要配置", + "sidebar.fps": "帧率", + "sidebar.load": "负载", + "sidebar.workspaces": "工作区", + "stream.error.clone_capture_failed": "克隆捕获模板失败", + "stream.error.clone_picture_failed": "克隆图片源失败", + "stream.error.clone_pp_failed": "克隆后处理模板失败", + "streams.add": "添加源", + "streams.add.processed": "添加处理源", + "streams.add.raw": "添加屏幕采集", + "streams.add.static_image": "添加静态图片源", + "streams.add.video": "添加视频源", + "streams.capture_template": "引擎模板:", + "streams.capture_template.hint": "定义屏幕采集方式的引擎模板", + "streams.created": "源创建成功", + "streams.delete.confirm": "确定要删除此源吗?", + "streams.deleted": "源删除成功", + "streams.description": "源定义采集管线。原始源使用采集模板从显示器采集。处理源对另一个源应用后处理。将源分配给设备。", + "streams.description_label": "描述(可选):", + "streams.description_placeholder": "描述此源...", + "streams.display": "显示器:", + "streams.display.hint": "采集哪个屏幕", + "streams.edit": "编辑源", + "streams.edit.processed": "编辑处理源", + "streams.edit.raw": "编辑屏幕采集", + "streams.edit.static_image": "编辑静态图片源", + "streams.edit.video": "编辑视频源", + "streams.error.delete": "删除源失败", + "streams.error.load": "加载源失败", + "streams.error.required": "请填写所有必填项", + "streams.group.assets": "资源", + "streams.group.audio": "音频", + "streams.group.audio_processing": "音频处理", + "streams.group.audio_templates": "音频模板", + "streams.group.color_strip": "色带源", + "streams.group.css_processing": "处理模板", + "streams.group.game": "游戏集成", + "streams.group.http": "HTTP", + "streams.group.proc_templates": "滤镜模板", + "streams.group.processed": "源", + "streams.group.raw": "源", + "streams.group.raw_templates": "引擎模板", + "streams.group.static_image": "静态图片", + "streams.group.sync": "同步时钟", + "streams.group.value": "值源", + "streams.group.video": "视频", + "streams.group.weather": "天气", + "streams.image_asset": "图片素材:", + "streams.image_asset.search": "搜索图片素材…", + "streams.image_asset.select": "选择图片素材…", + "streams.modal.loading": "加载中...", + "streams.name": "源名称:", + "streams.name.placeholder": "我的源", + "streams.pp_template": "滤镜模板:", + "streams.pp_template.hint": "要应用到源的滤镜模板", + "streams.section.streams": "源", + "streams.source": "源:", + "streams.source.hint": "要应用处理滤镜的源", + "streams.target_fps": "目标 FPS:", + "streams.target_fps.hint": "采集的目标帧率(1-90)", + "streams.test.duration": "采集时长(秒):", + "streams.test.error.failed": "源测试失败", + "streams.test.run": "运行", + "streams.test.running": "正在测试源...", + "streams.test.title": "测试源", + "streams.title": "输入", + "streams.type": "类型:", + "streams.type.processed": "已处理", + "streams.type.raw": "屏幕采集", + "streams.type.static_image": "静态图片", + "streams.updated": "源更新成功", + "streams.video_asset": "视频素材:", + "streams.video_asset.search": "搜索视频素材…", + "streams.video_asset.select": "选择视频素材…", + "sync_clock.action.pause": "暂停", + "sync_clock.action.reset": "重置", + "sync_clock.action.resume": "恢复", + "sync_clock.add": "添加同步时钟", + "sync_clock.created": "同步时钟已创建", + "sync_clock.delete.confirm": "删除此同步时钟?关联的源将失去同步并以默认速度运行。", + "sync_clock.deleted": "同步时钟已删除", + "sync_clock.description": "描述(可选):", + "sync_clock.description.hint": "关于此时钟用途的可选备注", + "sync_clock.description.placeholder": "可选描述", + "sync_clock.edit": "编辑同步时钟", + "sync_clock.elapsed": "已用时间", + "sync_clock.error.load": "加载同步时钟失败", + "sync_clock.error.name_required": "时钟名称为必填项", + "sync_clock.group.title": "同步时钟", + "sync_clock.name": "名称:", + "sync_clock.name.hint": "此同步时钟的描述性名称", + "sync_clock.name.placeholder": "主动画时钟", + "sync_clock.paused": "时钟已暂停", + "sync_clock.reset_done": "时钟已重置为零", + "sync_clock.resumed": "时钟已恢复", + "sync_clock.speed": "速度:", + "sync_clock.speed.hint": "所有关联源的动画速度倍率。1.0 = 正常,2.0 = 双倍,0.5 = 半速。", + "sync_clock.status.paused": "已暂停", + "sync_clock.status.running": "运行中", + "sync_clock.updated": "同步时钟已更新", + "tags.hint": "为卡片分配标签以进行分组和筛选", + "tags.label": "标签", + "tags.placeholder": "添加标签...", + "target.error.clone_failed": "克隆目标失败", + "target.error.delete_failed": "删除目标失败", + "target.error.editor_open_failed": "打开目标编辑器失败", + "target.error.load_failed": "加载目标失败", + "target.error.start_failed": "启动目标失败", + "target.error.stop_failed": "停止目标失败", + "targets.adaptive_fps": "自适应FPS:", + "targets.adaptive_fps.hint": "当设备无响应时自动降低发送速率,稳定后逐步恢复。推荐用于信号较弱的WiFi设备。", + "targets.add": "添加目标", + "targets.border_width": "边框宽度(像素):", + "targets.border_width.hint": "从屏幕边缘采样多少像素来确定 LED 颜色(1-100)", + "targets.brightness": "亮度:", + "targets.brightness.hint": "输出亮度乘数(0–1)。可绑定到值源进行动态控制。", + "targets.brightness_vs": "亮度源:", + "targets.brightness_vs.hint": "可选的值源,每帧动态控制亮度(覆盖设备亮度)", + "targets.brightness_vs.none": "无(设备亮度)", + "targets.button.start": "启动", + "targets.button.stop": "停止", + "targets.color_strip_source": "色带源:", + "targets.color_strip_source.hint": "选择为此目标提供 LED 颜色的色带源", + "targets.created": "目标创建成功", + "targets.delete.confirm": "确定要删除此目标吗?", + "targets.deleted": "目标删除成功", + "targets.description": "目标将色带源桥接到输出设备。每个目标引用一个设备和一个色带源。", + "targets.device": "设备:", + "targets.device.hint": "选择要发送数据的 LED 设备", + "targets.device.none": "-- 选择设备 --", + "targets.edit": "编辑目标", + "targets.error.delete": "删除目标失败", + "targets.error.load": "加载目标失败", + "targets.error.name_required": "请输入目标名称", + "targets.error.required": "请填写所有必填项", + "targets.failed": "加载目标失败", + "targets.fps": "目标 FPS:", + "targets.fps.hint": "采集和 LED 更新的目标帧率(1-90)", + "targets.fps.rec": "硬件最大 ≈ {fps} fps({leds} 个 LED)", + "targets.interpolation": "插值模式:", + "targets.interpolation.average": "平均", + "targets.interpolation.dominant": "主色", + "targets.interpolation.hint": "如何从采样像素计算 LED 颜色", + "targets.interpolation.median": "中位数", + "targets.keepalive_interval": "心跳间隔:", + "targets.keepalive_interval.hint": "源静态时重新发送最后一帧的频率,保持设备在活动模式(0.5-5.0秒)", + "targets.loading": "正在加载目标...", + "targets.metrics.actual_fps": "实际 FPS", + "targets.metrics.errors": "错误", + "targets.metrics.frames": "帧数", + "targets.metrics.target_fps": "目标 FPS", + "targets.min_brightness_threshold": "最低亮度阈值:", + "targets.min_brightness_threshold.hint": "当有效输出亮度(像素亮度 × 设备/源亮度)低于此值时,LED完全关闭(0 = 禁用)", + "targets.name": "目标名称:", + "targets.name.placeholder": "我的目标", + "targets.no_css": "无源", + "targets.none": "尚未配置目标", + "targets.power_limit": "最大电流 (ABL):", + "targets.power_limit.hint": "将灯带的估算电流限制在电源预算内,以防止明亮/白色场景下的电压骤降(颜色偏移、闪烁、重启)。请设为电源的额定电流并留有余量。0 = 不限制。", + "targets.power_limit.ma_suffix": "mA(0 = 不限制)", + "targets.power_limit.per_led": "每颗 LED 电流(全白):", + "targets.protocol": "协议:", + "targets.protocol.ddp": "DDP (UDP)", + "targets.protocol.ddp.desc": "快速UDP数据包 - 推荐", + "targets.protocol.hint": "DDP通过快速UDP发送像素(推荐)。HTTP使用JSON API——较慢但可靠,限制约500个LED。", + "targets.protocol.http": "HTTP", + "targets.protocol.http.desc": "JSON API - 较慢,≤500 LED", + "targets.protocol.serial": "串口", + "targets.protocol.udp": "WLED UDP(实时)", + "targets.protocol.udp.desc": "WLED 原生实时 — 正确的 RGBW 白色,断流时自动恢复", + "targets.section.advanced": "高级", + "targets.section.color_strips": "色带源", + "targets.section.devices": "设备", + "targets.section.pattern_templates": "图案模板", + "targets.section.specific_settings": "特定设置", + "targets.section.targets": "目标", + "targets.smoothing": "平滑:", + "targets.smoothing.hint": "帧间时间混合(0=无,1=完全)。减少闪烁。", + "targets.source": "源:", + "targets.source.hint": "要采集和处理的图片源", + "targets.source.none": "-- 未分配源 --", + "targets.status.error": "错误", + "targets.status.idle": "空闲", + "targets.status.processing": "处理中", + "targets.stop_all.button": "全部停止", + "targets.stop_all.error": "停止目标失败", + "targets.stop_all.none_running": "当前没有运行中的目标", + "targets.stop_all.stopped": "已停止 {count} 个目标", + "targets.subtab.led": "LED", + "targets.subtab.wled": "LED", + "targets.title": "通道", + "targets.updated": "目标更新成功", + "templates.add": "添加引擎模板", + "templates.config": "配置", + "templates.config.camera_backend.auto": "自动检测最佳后端", + "templates.config.camera_backend.dshow": "Windows DirectShow", + "templates.config.camera_backend.msmf": "Windows Media Foundation", + "templates.config.camera_backend.v4l2": "Linux Video4Linux2", + "templates.config.default": "默认", + "templates.config.none": "无额外配置", + "templates.config.resolution.1080p": "Full HD", + "templates.config.resolution.1440p": "QHD / 2K", + "templates.config.resolution.2160p": "4K UHD — CPU 与带宽最高", + "templates.config.resolution.480p": "VGA — CPU 与带宽最低", + "templates.config.resolution.720p": "HD", + "templates.config.resolution.auto": "以摄像头支持的最高模式打开", + "templates.config.show": "显示配置", + "templates.created": "模板创建成功", + "templates.delete.confirm": "确定要删除此模板吗?", + "templates.deleted": "模板删除成功", + "templates.description": "采集模板定义屏幕的采集方式。每个模板使用特定的采集引擎(MSS、DXcam、WGC)及自定义设置。将模板分配给设备以获得最佳性能。", + "templates.description.label": "描述(可选):", + "templates.description.placeholder": "描述此模板...", + "templates.edit": "编辑引擎模板", + "templates.empty": "尚未配置采集模板", + "templates.engine": "采集引擎:", + "templates.engine.android_camera.desc": "设备摄像头捕获 (Camera2)", + "templates.engine.bettercam.desc": "DirectX,高性能", + "templates.engine.camera.desc": "USB/IP摄像头捕获", + "templates.engine.demo.desc": "动画测试图案(演示模式)", + "templates.engine.dxcam.desc": "DirectX,低延迟", + "templates.engine.hint": "选择要使用的屏幕采集技术", + "templates.engine.mediaprojection.desc": "原生Android屏幕捕获", + "templates.engine.mss.desc": "跨平台,纯Python", + "templates.engine.scrcpy.desc": "Android屏幕镜像 (adb screencap)", + "templates.engine.scrcpy_client.desc": "Android H.264流,高帧率", + "templates.engine.select": "选择引擎...", + "templates.engine.unavailable": "不可用", + "templates.engine.unavailable.hint": "此引擎在您的系统上不可用", + "templates.engine.wgc.desc": "Windows图形捕获", + "templates.error.delete": "删除模板失败", + "templates.error.engines": "加载引擎失败", + "templates.error.load": "加载模板失败", + "templates.error.load_failed": "加载模板失败", + "templates.error.required": "请填写所有必填项", + "templates.error.save_failed": "保存模板失败", + "templates.loading": "正在加载模板...", + "templates.name": "模板名称:", + "templates.name.placeholder": "我的自定义模板", + "templates.test.border_width": "边框宽度(像素):", + "templates.test.description": "保存前测试此模板,查看采集预览和性能指标。", + "templates.test.device": "设备:", + "templates.test.device.select": "选择设备...", + "templates.test.display": "显示器:", + "templates.test.display.select": "选择显示器...", + "templates.test.duration": "采集时长(秒):", + "templates.test.error.failed": "测试失败", + "templates.test.error.no_device": "请选择设备", + "templates.test.error.no_display": "请选择显示器", + "templates.test.error.no_engine": "请选择采集引擎", + "templates.test.results.actual_fps": "实际 FPS", + "templates.test.results.avg_capture_time": "平均采集", + "templates.test.results.borders": "边框提取", + "templates.test.results.bottom": "下", + "templates.test.results.capture_time": "采集", + "templates.test.results.duration": "时长", + "templates.test.results.extraction_time": "提取", + "templates.test.results.frame_count": "帧数", + "templates.test.results.left": "左", + "templates.test.results.max_fps": "最大 FPS", + "templates.test.results.performance": "性能", + "templates.test.results.preview": "全幅采集预览", + "templates.test.results.resolution": "分辨率:", + "templates.test.results.right": "右", + "templates.test.results.top": "上", + "templates.test.results.total_time": "总计", + "templates.test.run": "运行", + "templates.test.running": "正在运行测试...", + "templates.test.title": "测试采集", + "templates.title": "引擎模板", + "templates.updated": "模板更新成功", + "test.avg_capture": "平均", + "test.fps": "帧率", + "test.frames": "帧数", + "theme.switched.dark": "已切换到深色主题", + "theme.switched.light": "已切换到浅色主题", + "theme.switched.system": "已切换到系统主题", + "theme.toggle": "切换主题", + "time.days_ago": "{n}天前", + "time.hours_ago": "{n}小时前", + "time.just_now": "刚刚", + "time.minutes_ago": "{n}分钟前", + "time.seconds_ago": "{n}秒前", + "time.today": "今天", + "time.yesterday": "昨天", + "tour.accent": "主题色 — 自定义界面的强调颜色。", + "tour.activity_log": "活动标签页是 LedGrab 所有操作的持久审计日志——实体更改、身份验证事件、设备连接和系统操作。可过滤、搜索,并导出为 CSV 或 JSON 格式。", + "tour.api": "API 文档 — 基于 Swagger 的交互式 REST API 文档。", + "tour.auto.add": "点击 + 创建包含条件和要激活场景的新自动化。", + "tour.auto.card": "每张卡片显示自动化状态、条件和快速编辑/切换控制。", + "tour.auto.list": "自动化 — 基于时间、音频或数值条件自动激活场景。", + "tour.auto.scenes_add": "点击 + 将当前系统状态捕获为新的场景预设。", + "tour.auto.scenes_card": "每个场景卡片显示目标/设备数量。点击编辑、重新捕获或激活。", + "tour.auto.scenes_list": "场景 — 保存的系统状态,自动化可以激活或您可以手动应用。", + "tour.automations": "自动化 — 通过时间、音频或数值条件自动切换场景。", + "tour.dash.automations": "自动化 — 活动自动化状态和快速启用/禁用切换。", + "tour.dash.customize_btn": "自定义按钮 — 打开侧面板,可重新排序、隐藏或调整仪表盘布局。", + "tour.dash.customize_global": "全局设置 — 内容宽度、动画级别、性能模式(system/app/both)和图表采样窗口。", + "tour.dash.customize_panel": "自定义仪表盘 — 布局控制中心。更改即时生效;按 Esc 或 × 关闭。", + "tour.dash.customize_perf_cells": "性能单元 — 选择性能部分中显示哪些指标,配置跨度、采样窗口和 Y 轴比例。", + "tour.dash.customize_presets": "预设 — 一键应用精选布局(紧凑、聚焦、完整)。切换预设会覆盖任何手动调整。", + "tour.dash.customize_sections": "部分 — 拖拽以重新排序;「眼睛」可隐藏;「箭头」可设为默认折叠。每个部分还可选择密度(密集/紧凑/舒适)。", + "tour.dash.integrations": "集成 — Weather、Home Assistant 和 MQTT 源的连接状态。", + "tour.dash.perf": "性能 — 实时 FPS 图表、延迟指标和轮询间隔控制。", + "tour.dash.running": "运行中的目标 — 实时流媒体指标和快速停止控制。", + "tour.dash.scenes": "场景预设 — 已保存的系统快照,一键应用。", + "tour.dash.stopped": "已停止的目标 — 一键启动。", + "tour.dash.sync_clocks": "同步时钟 — 跨多个源同步动画的共享计时器。", + "tour.dash.targets": "通道 — 您的所有目标集中显示,按运行中和已停止分组。", + "tour.dashboard": "仪表盘 — 实时查看运行中的目标、自动化和设备状态。", + "tour.graph": "图表 — 所有实体及其连接的可视化概览。拖动端口进行连接,右键单击边线断开连接。", + "tour.int.game": "游戏 — 根据游戏内事件(Minecraft、Hammer of God 等)触发场景和色彩效果。", + "tour.int.home_assistant": "Home Assistant — 连接到您的 HA 实例。灯、传感器和实体可作为数值源和目标端点。", + "tour.int.mqtt": "MQTT — 订阅代理主题,将外部传感器数据、自动化或远程触发器传入 LedGrab。", + "tour.int.nav": "使用此下拉菜单在集成类型之间切换:天气、Home Assistant、MQTT 和游戏集成。", + "tour.int.weather": "天气 — 获取实时天气数据(温度、状况、预报)作为环境效果的数值源。", + "tour.integrations": "集成 — 连接外部服务:天气、Home Assistant、MQTT 和游戏集成。", + "tour.language": "语言 — 选择您偏好的界面语言。", + "tour.restart": "重新开始导览", + "tour.search": "搜索 — 使用 Ctrl+K 快速查找并导航到任意实体。", + "tour.settings": "设置 — 备份和恢复配置,管理自动备份。", + "tour.sources": "来源 — 管理捕获模板、图片来源、音频来源和色带。", + "tour.src.audio": "音频 — 捕获麦克风/系统音频,并通过模板进行处理以生成反应式效果。", + "tour.src.color_strip": "色带 — 定义屏幕区域如何映射到 LED 段。", + "tour.src.nav": "使用此下拉菜单在源类型之间切换 — 图片、色带、音频、数值、同步和资源。", + "tour.src.processed": "处理 — 应用后处理效果,如模糊、亮度或色彩校正。", + "tour.src.raw": "原始 — 来自显示器的实时屏幕捕获源。", + "tour.src.static": "静态图片 — 使用图片文件测试您的设置。", + "tour.src.sync": "同步时钟 — 在多个源之间同步动画的共享定时器。", + "tour.src.templates": "捕获模板 — 可复用的捕获配置(分辨率、FPS、裁剪)。", + "tour.src.value": "数值源 — 由时间、音频、系统指标、Home Assistant 实体、渐变或日程驱动的动态数字或颜色。用于动画效果、调制色带和触发自动化。", + "tour.targets": "目标 — 添加 WLED 设备,配置 LED 目标的捕获设置和校准。", + "tour.tgt.css": "色带 — 定义屏幕区域如何映射到 LED 段。", + "tour.tgt.devices": "设备 — 在网络中发现的 LED 控制器。", + "tour.tgt.ha_light_targets": "HA 灯目标 — 选择 Home Assistant 灯实体和颜色/数值源进行驱动。", + "tour.tgt.nav": "使用此下拉菜单在目标类型之间切换:LED 设备、LED 目标和 Home Assistant 灯。", + "tour.tgt.targets": "LED 目标 — 将设备、色带和捕获源组合进行流式传输。", + "tour.theme": "主题 — 在深色和浅色模式之间切换。", + "tour.welcome": "欢迎使用 LED Grab!快速导览将带您了解界面。使用方向键或按钮进行导航。", + "transport.meta.cpu": "CPU", + "transport.meta.mem": "内存", + "transport.meta.poll": "轮询", + "transport.meta.poll_hint": "轮询间隔(点击:1秒 → 2秒 → 5秒 → 10秒)", + "transport.meta.uptime": "在线", + "transport.status.armed": "运行中 · {n}", + "transport.status.ready": "就绪", + "tree.group.audio": "音频", + "tree.group.audio_capture": "采集", + "tree.group.audio_processed": "处理", + "tree.group.capture": "屏幕采集", + "tree.group.game": "游戏", + "tree.group.picture": "图片源", + "tree.group.processing": "已处理", + "tree.group.static": "静态", + "tree.group.strip": "色带", + "tree.group.utility": "工具", + "tree.leaf.engine_templates": "引擎模板", + "tree.leaf.filter_templates": "滤镜模板", + "tree.leaf.images": "图片", + "tree.leaf.processing_templates": "处理模板", + "tree.leaf.sources": "源", + "tree.leaf.templates": "模板", + "tree.leaf.video": "视频", + "update.apply_confirm": "下载并安装版本 {version}?服务器将自动重启。", + "update.apply_error": "更新失败", + "update.apply_now": "立即更新", + "update.applying": "正在应用更新…", + "update.assets.desc.android": "Android — 在 Android 7.0+ 上侧载", + "update.assets.desc.android_bundle": "Android App Bundle(Play Store)", + "update.assets.desc.archive": "压缩归档", + "update.assets.desc.ios": "iOS 应用", + "update.assets.desc.linux_appimage": "Linux 便携版 — 单文件可执行", + "update.assets.desc.linux_deb": "Debian / Ubuntu 软件包", + "update.assets.desc.linux_rpm": "Fedora / RHEL 软件包", + "update.assets.desc.linux_sandbox": "Linux 沙盒软件包", + "update.assets.desc.linux_tarball": "Linux 归档 — 解压后运行 ./run.sh", + "update.assets.desc.macos_dmg": "macOS 磁盘镜像 — 拖入「应用程序」", + "update.assets.desc.macos_installer": "macOS 安装包", + "update.assets.desc.tarball": "Tar 归档", + "update.assets.desc.windows_exe": "Windows 可执行文件", + "update.assets.desc.windows_installer": "Windows 安装程序 — 开始菜单快捷方式、可选自启动、卸载器", + "update.assets.desc.windows_msi": "Windows MSI 安装程序", + "update.assets.desc.windows_portable": "Windows 便携版 — 解压后运行 LedGrab.bat", + "update.assets.desc.zip_archive": "ZIP 归档", + "update.assets.title": "下载", + "update.auto_check_hint": "在后台定期检查新版本。", + "update.auto_check_label": "自动检查设置", + "update.available": "版本 {version} 可用", + "update.badge_tooltip": "有新版本可用 — 点击查看详情", + "update.channel.prerelease": "预发布", + "update.channel.prerelease_desc": "包括 alpha、beta 和 RC 版本", + "update.channel.stable": "稳定版", + "update.channel.stable_desc": "仅稳定版本", + "update.channel_label": "频道", + "update.check_error": "检查更新失败", + "update.check_now": "检查更新", + "update.current_version": "当前版本:", + "update.dismiss": "忽略", + "update.downloading": "正在下载…", + "update.enable": "启用自动检查", + "update.install_type.dev": "开发环境", + "update.install_type.docker": "Docker", + "update.install_type.installer": "Windows 安装程序", + "update.install_type.portable": "便携版", + "update.install_type_label": "安装类型:", + "update.interval_label": "检查间隔", + "update.last_check": "上次检查", + "update.never": "从未", + "update.pill.available": "可用更新", + "update.pill.error": "错误", + "update.pill.updated": "已是最新", + "update.prerelease": "预发布", + "update.release_notes": "发布说明", + "update.release_notes_hint": "当前可用版本的更新内容 — 应用前请先查看变更日志。", + "update.release_notes_open": "打开", + "update.save_settings": "保存设置", + "update.settings_save_error": "保存更新设置失败", + "update.settings_saved": "更新设置已保存", + "update.status_label": "更新状态", + "update.up_to_date": "已是最新版本", + "update.view_release": "查看发布", + "update.view_release_notes": "查看发布说明", + "validation.required": "此字段为必填项", + "value_source.adaptive_max_value": "最大值:", + "value_source.adaptive_max_value.hint": "最大输出亮度", + "value_source.adaptive_min_value": "最小值:", + "value_source.adaptive_min_value.hint": "最小输出亮度", + "value_source.add": "添加值源", + "value_source.audio_max_value": "最大值:", + "value_source.audio_max_value.hint": "最大音频电平时的输出", + "value_source.audio_min_value": "最小值:", + "value_source.audio_min_value.hint": "音频静默时的输出(例如 0.3 = 30% 亮度下限)", + "value_source.audio_source": "音频源:", + "value_source.audio_source.hint": "要读取音频电平的音频源", + "value_source.auto_gain": "自动增益:", + "value_source.auto_gain.enable": "启用自动增益", + "value_source.auto_gain.hint": "自动归一化音频电平,使输出使用完整范围,无论输入音量大小", + "value_source.created": "值源已创建", + "value_source.daylight.enable_real_time": "跟随系统时钟", + "value_source.daylight.latitude": "纬度:", + "value_source.daylight.latitude.hint": "地理纬度(-90到90)。使日出/日落过渡更陡峭或更平缓。", + "value_source.daylight.longitude": "经度:", + "value_source.daylight.longitude.hint": "地理经度(-180到180)。偏移正午时刻,使日出/日落与挂钟时间对齐。", + "value_source.daylight.real_time": "实时", + "value_source.daylight.speed": "速度:", + "value_source.daylight.speed.hint": "周期速度倍率。1.0 = 完整日夜周期约4分钟。", + "value_source.daylight.speed_label": "速度", + "value_source.daylight.use_real_time": "使用实时:", + "value_source.daylight.use_real_time.hint": "启用后,数值跟随实际时间。速度设置将被忽略。", + "value_source.delete.confirm": "确定要删除此值源吗?", + "value_source.deleted": "值源已删除", + "value_source.description": "描述(可选):", + "value_source.description.hint": "关于此值源的可选说明", + "value_source.description.placeholder": "描述此值源...", + "value_source.edit": "编辑值源", + "value_source.error.load": "加载数值源失败", + "value_source.error.name_required": "请输入名称", + "value_source.error.schedule_min": "计划至少需要 2 个时间点", + "value_source.game_event.default_value": "默认值:", + "value_source.game_event.default_value.hint": "在超时时间内未收到事件时的输出值。", + "value_source.game_event.event_type": "事件类型:", + "value_source.game_event.event_type.hint": "要跟踪的持续游戏事件(生命值、法力、弹药等)。", + "value_source.game_event.integration": "游戏集成:", + "value_source.game_event.integration.hint": "选择为此值源提供事件的游戏集成。", + "value_source.game_event.max_game_value": "最大游戏值:", + "value_source.game_event.max_game_value.hint": "映射到输出1.0的原始游戏值。", + "value_source.game_event.min_game_value": "最小游戏值:", + "value_source.game_event.min_game_value.hint": "映射到输出0.0的原始游戏值。", + "value_source.game_event.smoothing": "平滑:", + "value_source.game_event.smoothing.hint": "EMA平滑系数。0 = 即时,越高越平滑。", + "value_source.game_event.timeout": "超时(秒):", + "value_source.game_event.timeout.hint": "恢复到默认值前的静默秒数。", + "value_source.group.title": "值源", + "value_source.http.endpoint": "HTTP 端点:", + "value_source.http.endpoint.hint": "从 HTTP 集成选项卡中选择已保存的端点。", + "value_source.http.endpoint_required": "需要 HTTP 端点", + "value_source.http.interval": "间隔(秒):", + "value_source.http.interval.hint": "轮询间隔(秒)。多个值源可以以不同间隔共享同一端点。", + "value_source.http.interval_invalid": "间隔至少为 1 秒", + "value_source.http.json_path": "JSON 路径:", + "value_source.http.json_path.hint": "为空则使用原始响应体。使用点号/索引路径,例如 MediaContainer.Metadata[0].title", + "value_source.http.max_value": "最大值:", + "value_source.http.max_value.hint": "映射到输出 1.0 的原始值(用于归一化)。", + "value_source.http.min_value": "最小值:", + "value_source.http.min_value.hint": "映射到输出 0.0 的原始值(用于归一化)。", + "value_source.http.modulator.hint": "仅当此源用于驱动亮度或颜色时使用。自动化规则会直接读取提取的原始值,并忽略这些设置。", + "value_source.http.modulator.summary": "调制映射(可选)", + "value_source.max_value": "最大值:", + "value_source.max_value.hint": "波形周期的最大输出", + "value_source.min_value": "最小值:", + "value_source.min_value.hint": "波形周期的最小输出", + "value_source.mode": "模式:", + "value_source.mode.beat": "节拍", + "value_source.mode.beat.desc": "节奏脉冲检测", + "value_source.mode.hint": "RMS 测量平均音量。峰值跟踪最响的时刻。节拍在节奏上触发。", + "value_source.mode.peak": "峰值", + "value_source.mode.peak.desc": "最响时刻追踪", + "value_source.mode.rms": "RMS(音量)", + "value_source.mode.rms.desc": "平均音量水平", + "value_source.name": "名称:", + "value_source.name.hint": "此值源的描述性名称", + "value_source.name.placeholder": "亮度脉冲", + "value_source.normalize": "归一化到 0–1:", + "value_source.normalize.hint": "开启:使用最小/最大值将原始值缩放到 0–1。关闭:直接将数值钳制到 0–1(适用于本身就输出 0–1 比例的来源)。原始值始终可在模板(raw[name])和自动化中使用。", + "value_source.picture_source": "图片源:", + "value_source.picture_source.hint": "将分析其帧以获取平均亮度的图片源。", + "value_source.scene_behavior": "行为:", + "value_source.scene_behavior.complement": "互补(暗 → 亮)", + "value_source.scene_behavior.hint": "互补:暗场景 = 高亮度(适合环境背光)。匹配:亮场景 = 高亮度。", + "value_source.scene_behavior.match": "匹配(亮 → 亮)", + "value_source.scene_sensitivity.hint": "亮度信号的增益倍数(越高对亮度变化越敏感)", + "value_source.schedule": "计划:", + "value_source.schedule.add": "+ 添加时间点", + "value_source.schedule.hint": "定义至少 2 个时间点。亮度在各点之间线性插值,午夜循环。", + "value_source.schedule.points": "个时间点", + "value_source.select_type": "选择值源类型", + "value_source.sensitivity": "灵敏度:", + "value_source.sensitivity.hint": "音频信号的增益倍数(越高反应越灵敏)", + "value_source.smoothing": "平滑:", + "value_source.smoothing.hint": "时间平滑(0 = 即时响应,1 = 非常平滑/缓慢)", + "value_source.speed": "速度(周期/分):", + "value_source.speed.hint": "每分钟周期数 — 波形重复的速度(1 = 非常慢,120 = 非常快)", + "value_source.template.add_input": "+ 添加输入", + "value_source.template.default_value": "默认值:", + "value_source.template.default_value.hint": "当表达式无法求值(例如缺少输入)时使用的输出值。", + "value_source.template.error.cycle": "该表达式会造成依赖循环", + "value_source.template.error.duplicate_name": "输入名称重复", + "value_source.template.error.invalid_expr": "表达式无效", + "value_source.template.error.invalid_name": "变量名无效", + "value_source.template.error.missing_input": "每个输入都需要一个变量名", + "value_source.template.error.reserved_name": "保留名称不能用作输入", + "value_source.template.error.unbound": "表达式引用了未绑定的变量", + "value_source.template.eval_interval": "求值间隔(秒):", + "value_source.template.eval_interval.hint": "重新求值表达式的频率。0 = 每次轮询(随输入更新一样快地重新求值)。", + "value_source.template.expression": "表达式:", + "value_source.template.expression.hint": "返回数字的沙盒化 Jinja 表达式。已绑定的输入可按名称使用;使用 raw[name] 获取未归一化的值。", + "value_source.template.expression.placeholder": "clamp((temp - 18) / 10, 0, 1)", + "value_source.template.hints.examples_title": "示例", + "value_source.template.hints.globals": "min(a, b), max(a, b), abs(x), round(x), clamp(x, lo, hi)", + "value_source.template.hints.globals_title": "全局函数", + "value_source.template.hints.inputs_title": "已绑定的输入", + "value_source.template.hints.no_inputs": "尚未绑定输入", + "value_source.template.hints.raw": "raw[name] 给出某个输入的未归一化值(如果有)。", + "value_source.template.hints.raw_title": "原始值", + "value_source.template.hints.time": "提示:要实现一天中的时间逻辑,请将“自适应(时间)”或“日光周期”源绑定为输入。", + "value_source.template.hints.title": "表达式帮助", + "value_source.template.input_count": "个输入", + "value_source.template.input_count_one": "个输入", + "value_source.template.input_name": "变量名", + "value_source.template.inputs": "输入:", + "value_source.template.inputs.empty": "暂无输入。点击 + 绑定一个值源。", + "value_source.template.inputs.hint": "将浮点值源绑定到你在表达式中引用的变量名。", + "value_source.template.valid": "表达式有效", + "value_source.test": "测试", + "value_source.test.connecting": "连接中...", + "value_source.test.current": "当前", + "value_source.test.error": "连接失败", + "value_source.test.max": "最大", + "value_source.test.min": "最小", + "value_source.test.title": "测试值源", + "value_source.type": "类型:", + "value_source.type.adaptive_scene": "自适应(场景)", + "value_source.type.adaptive_scene.desc": "按场景内容调节", + "value_source.type.adaptive_time": "自适应(时间)", + "value_source.type.adaptive_time.desc": "按时间自动调节", + "value_source.type.animated": "动画", + "value_source.type.animated.desc": "循环波形变化", + "value_source.type.audio": "音频", + "value_source.type.audio.desc": "响应声音输入", + "value_source.type.daylight": "日光周期", + "value_source.type.daylight.desc": "数值跟随昼夜周期", + "value_source.type.game_event": "游戏事件", + "value_source.type.game_event.desc": "游戏指标(生命值、弹药、法力)作为0-1值", + "value_source.type.hint": "静态输出固定值。动画循环波形。音频响应声音输入。自适应类型根据时间或场景内容自动调节亮度。", + "value_source.type.http": "HTTP 轮询", + "value_source.type.http.desc": "按固定间隔轮询 HTTP 端点,并将提取的值映射到 0–1。", + "value_source.type.static": "静态", + "value_source.type.static.desc": "固定输出值", + "value_source.type.template": "Jinja 模板", + "value_source.type.template.desc": "使用沙盒化的 Jinja 表达式组合已绑定的输入,计算出 0-1 的值。", + "value_source.updated": "值源已更新", + "value_source.value": "值:", + "value_source.value.hint": "固定输出值(0.0 = 关闭,1.0 = 最大亮度)", + "value_source.waveform": "波形:", + "value_source.waveform.hint": "亮度动画循环的形状", + "value_source.waveform.sawtooth": "锯齿", + "value_source.waveform.sine": "正弦", + "value_source.waveform.square": "方波", + "value_source.waveform.triangle": "三角", + "weather_source.add": "添加天气源", + "weather_source.created": "天气源已创建", + "weather_source.delete.confirm": "删除此天气源?关联的色带源将失去天气数据。", + "weather_source.deleted": "天气源已删除", + "weather_source.description": "描述(可选):", + "weather_source.description.placeholder": "可选描述", + "weather_source.edit": "编辑天气源", + "weather_source.error.load": "加载天气源失败", + "weather_source.error.name_required": "天气源名称为必填项", + "weather_source.geo.error": "地理定位失败", + "weather_source.geo.not_supported": "您的浏览器不支持地理定位", + "weather_source.geo.success": "位置已检测", + "weather_source.group.title": "天气源", + "weather_source.latitude": "纬度:", + "weather_source.location": "位置:", + "weather_source.location.hint": "您的地理坐标。使用自动检测按钮或手动输入。", + "weather_source.longitude": "经度:", + "weather_source.name": "名称:", + "weather_source.name.hint": "天气数据源的描述性名称", + "weather_source.name.placeholder": "我的天气", + "weather_source.provider": "提供商:", + "weather_source.provider.hint": "天气数据提供商。Open-Meteo免费且无需API密钥。", + "weather_source.provider.open_meteo.desc": "免费,无需API密钥", + "weather_source.test": "测试", + "weather_source.update_interval": "更新间隔:", + "weather_source.update_interval.hint": "获取天气数据的频率。值越低,更新越及时。", + "weather_source.updated": "天气源已更新", + "weather_source.use_my_location": "使用我的位置", + "weekday.short.0": "周一", + "weekday.short.1": "周二", + "weekday.short.2": "周三", + "weekday.short.3": "周四", + "weekday.short.4": "周五", + "weekday.short.5": "周六", + "weekday.short.6": "周日", + "wizard.calibrate.desc": "告诉 LedGrab 您的 LED 灯带从哪里开始,以及它如何绕屏幕布置。", + "wizard.calibrate.skip": "跳过校准", + "wizard.calibrate.title": "校准灯带布局", + "wizard.device.desc": "扫描网络查找兼容的 LED 控制器,或手动添加。", + "wizard.device.discovered": "在网络中发现", + "wizard.device.existing": "已有设备", + "wizard.device.manual.add": "添加设备", + "wizard.device.manual.led_count": "LED 数量", + "wizard.device.manual.name": "设备名称", + "wizard.device.manual.name_placeholder": "我的 LED 灯带", + "wizard.device.manual.title": "手动添加", + "wizard.device.manual.url": "设备地址", + "wizard.device.none_found": "未找到设备。请尝试手动添加。", + "wizard.device.rescan": "重新扫描", + "wizard.device.scanning": "正在扫描网络…", + "wizard.device.title": "查找您的设备", + "wizard.display.confirm": "使用此屏幕", + "wizard.display.desc": "选择用于采集氛围灯的显示器。", + "wizard.display.index_prefix": "显示器", + "wizard.display.loading": "正在加载显示器…", + "wizard.display.manual_index": "显示器序号", + "wizard.display.no_displays": "未检测到显示器。请手动输入显示器序号。", + "wizard.display.primary": "主显示器", + "wizard.display.title": "选择您的屏幕", + "wizard.done.desc": "您的氛围 LED 设置已激活。尽情享受灯光吧!", + "wizard.done.device": "设备", + "wizard.done.display": "屏幕", + "wizard.done.finish": "完成", + "wizard.done.title": "全部完成!", + "wizard.error.device_create_failed": "创建设备失败。", + "wizard.error.device_name_required": "设备名称不能为空。", + "wizard.error.device_url_required": "设备地址不能为空。", + "wizard.error.no_device": "请先选择或添加一个设备。", + "wizard.error.scaffold_failed": "配置失败,请重试。", + "wizard.error.start_failed": "启动 LED 输出失败。", + "wizard.modal.title": "设置向导", + "wizard.rerun": "重新运行设置向导", + "wizard.scaffold.building": "正在创建实体…", + "wizard.scaffold.desc": "正在创建采集链:屏幕源 → 色带 → LED 输出。", + "wizard.scaffold.done": "配置完成!准备好进行校准。", + "wizard.scaffold.title": "正在创建配置", + "wizard.skip": "跳过", + "wizard.start": "开始设置", + "wizard.start.done": "LED 输出正在运行!", + "wizard.start.failed": "启动输出失败。您可以在「目标」选项卡中手动启动。", + "wizard.start.starting": "正在启动 LED 输出…", + "wizard.start.title": "正在启动输出", + "wizard.step.calibrate": "校准", + "wizard.step.device": "设备", + "wizard.step.display": "屏幕", + "wizard.step.done": "完成", + "wizard.step.scaffold": "配置", + "wizard.step.start": "启动", + "wizard.step.welcome": "欢迎", + "wizard.welcome.desc": "只需几步,即可启动并运行您的 LED 灯带。", + "wizard.welcome.item1": "连接您的 LED 控制器", + "wizard.welcome.item2": "选择要采集的屏幕", + "wizard.welcome.item3": "校准灯带布局", + "wizard.welcome.item4": "启动氛围灯输出", + "wizard.welcome.title": "欢迎使用 LED Grab" } diff --git a/server/src/ledgrab/storage/activity_log.py b/server/src/ledgrab/storage/activity_log.py new file mode 100644 index 0000000..84ff3df --- /dev/null +++ b/server/src/ledgrab/storage/activity_log.py @@ -0,0 +1,171 @@ +"""Activity / audit log data model. + +Defines the ``ActivityLogEntry`` dataclass together with its category and +severity enumerations and the ``ActivityLogFilters`` query object. Row-level +codec (``to_row`` / ``from_row``) converts between the dataclass and a flat +SQLite column dict. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import Sequence + + +# --------------------------------------------------------------------------- +# Enumerations (string constants, not enum.Enum, to stay consistent with the +# rest of the codebase which uses plain string literals) +# --------------------------------------------------------------------------- + + +class ActivityCategory: + """Valid ``category`` values for an ``ActivityLogEntry``.""" + + AUTH = "auth" + DEVICE = "device" + ENTITY = "entity" + CAPTURE = "capture" + SYSTEM = "system" + + ALL: tuple[str, ...] = (AUTH, DEVICE, ENTITY, CAPTURE, SYSTEM) + + +class ActivitySeverity: + """Valid ``severity`` values for an ``ActivityLogEntry``.""" + + INFO = "info" + WARNING = "warning" + ERROR = "error" + + ALL: tuple[str, ...] = (INFO, WARNING, ERROR) + + +# --------------------------------------------------------------------------- +# Entry dataclass +# --------------------------------------------------------------------------- + + +@dataclass +class ActivityLogEntry: + """One immutable audit record. + + Fields + ------ + id Stable application-assigned identifier, e.g. ``al_``. + ts UTC timestamp of the event (server-assigned at record time). + category Broad bucket — one of :class:`ActivityCategory`. + action Verb-object label, e.g. ``"entity.created"`` or ``"auth.rejected"``. + severity One of :class:`ActivitySeverity`. + actor Who triggered the action, e.g. an API-key label or ``"system"``. + entity_type Optional: kind of entity involved, e.g. ``"output_target"``. + entity_id Optional: stable entity identifier. + entity_name Optional: human-readable entity name at the time of the event. + message Human-readable description suitable for display. + metadata Small structured context (device address, error code, …). + Must be JSON-serialisable; defaults to empty dict. + """ + + id: str + ts: datetime + category: str + action: str + severity: str + actor: str + message: str + entity_type: str | None = None + entity_id: str | None = None + entity_name: str | None = None + metadata: dict = field(default_factory=dict) + + # -- Row codec ----------------------------------------------------------- + + def to_row(self) -> dict: + """Return a flat dict suitable for a parameterised SQLite INSERT. + + ``ts`` is stored as an ISO-8601 string (UTC). ``metadata`` is stored + as a JSON string. ``seq`` is DB-assigned and is NOT included. + """ + return { + "id": self.id, + "ts": self.ts.isoformat(), + "category": self.category, + "action": self.action, + "severity": self.severity, + "actor": self.actor, + "entity_type": self.entity_type, + "entity_id": self.entity_id, + "entity_name": self.entity_name, + "message": self.message, + "metadata": json.dumps(self.metadata, ensure_ascii=False), + } + + @staticmethod + def from_row(row: dict) -> "ActivityLogEntry": + """Reconstruct an ``ActivityLogEntry`` from a SQLite row dict. + + ``row`` may include the ``seq`` column — it is ignored here (callers + that need ``seq`` for keyset pagination access it directly from the + row before calling this method). + """ + raw_meta = row.get("metadata") or "{}" + try: + metadata = json.loads(raw_meta) + except (json.JSONDecodeError, TypeError): + metadata = {} + + raw_ts = row["ts"] + ts = datetime.fromisoformat(raw_ts) + # Ensure tz-aware UTC even if stored without offset (legacy rows) + if ts.tzinfo is None: + ts = ts.replace(tzinfo=timezone.utc) + + return ActivityLogEntry( + id=row["id"], + ts=ts, + category=row["category"], + action=row["action"], + severity=row["severity"], + actor=row["actor"], + entity_type=row.get("entity_type"), + entity_id=row.get("entity_id"), + entity_name=row.get("entity_name"), + message=row["message"], + metadata=metadata, + ) + + +# --------------------------------------------------------------------------- +# Filters dataclass +# --------------------------------------------------------------------------- + + +@dataclass +class ActivityLogFilters: + """Optional query-time filters for :class:`ActivityLogRepository`. + + All fields are optional. ``None`` / empty sequence means "no restriction + on this dimension". + + Fields + ------ + categories Restrict to entries whose ``category`` is in this set. + severities Restrict to entries whose ``severity`` is in this set. + actor Exact match on the ``actor`` field. + entity_type Exact match on the ``entity_type`` field. + entity_id Exact match on the ``entity_id`` field. + since Inclusive lower bound on ``ts`` (``ts >= since``). + until Inclusive upper bound on ``ts`` (``ts <= until``). + message_like Free-text substring match on ``message`` (LIKE ``%value%``). + The value is escaped — no SQL injection risk. + """ + + categories: Sequence[str] | None = None + severities: Sequence[str] | None = None + actor: str | None = None + entity_type: str | None = None + entity_id: str | None = None + since: datetime | None = None + until: datetime | None = None + message_like: str | None = None diff --git a/server/src/ledgrab/storage/activity_log_repository.py b/server/src/ledgrab/storage/activity_log_repository.py new file mode 100644 index 0000000..b1df695 --- /dev/null +++ b/server/src/ledgrab/storage/activity_log_repository.py @@ -0,0 +1,338 @@ +"""Append-only repository for the persistent activity / audit log. + +Design notes +------------ +* Does NOT subclass ``BaseSqliteStore`` — that base loads every row into an + in-memory cache at construction time, which is wrong for an unbounded, + append-heavy log. +* All SQL parameters are passed as positional ``?`` placeholders — no user + input is interpolated into the query string. +* Keyset pagination is implemented via the ``seq`` INTEGER PRIMARY KEY + AUTOINCREMENT column, which is strictly monotonic and survives rows with + identical ``ts`` values. +* The repository takes a ``Database`` instance and calls ``db.execute`` / + ``db.transaction`` directly. +* Thread safety: ``Database.execute`` holds the ``RLock`` for the duration of + each call. The repository itself adds no extra locking. Callers that + originate from non-event-loop threads MUST marshal via + ``loop.call_soon_threadsafe`` (that is Phase 2's responsibility). +""" + +from __future__ import annotations + +from datetime import datetime +from typing import Iterator + +from ledgrab.storage.activity_log import ActivityLogEntry, ActivityLogFilters +from ledgrab.storage.database import Database +from ledgrab.utils import get_logger + +logger = get_logger(__name__) + +_TABLE = "activity_log" + + +def _build_filter_clause( + filters: ActivityLogFilters, + params: list, + *, + extra_where: str | None = None, +) -> str: + """Return a WHERE clause string and append bound parameters to *params*. + + The caller is responsible for providing the opening ``WHERE`` keyword when + the returned string is non-empty, or ``AND`` when appending to an existing + predicate. This function returns only the condition fragment(s) joined by + ``AND``, or an empty string when *filters* has no restrictions. + + ``extra_where`` is prepended (with AND) before the filter conditions if + provided (used for the ``seq < ?`` keyset predicate). + """ + conditions: list[str] = [] + + if extra_where: + conditions.append(extra_where) + + if filters.categories: + placeholders = ",".join("?" * len(filters.categories)) + conditions.append(f"category IN ({placeholders})") + params.extend(filters.categories) + + if filters.severities: + placeholders = ",".join("?" * len(filters.severities)) + conditions.append(f"severity IN ({placeholders})") + params.extend(filters.severities) + + if filters.actor is not None: + conditions.append("actor = ?") + params.append(filters.actor) + + if filters.entity_type is not None: + conditions.append("entity_type = ?") + params.append(filters.entity_type) + + if filters.entity_id is not None: + conditions.append("entity_id = ?") + params.append(filters.entity_id) + + if filters.since is not None: + conditions.append("ts >= ?") + params.append(filters.since.isoformat()) + + if filters.until is not None: + conditions.append("ts <= ?") + params.append(filters.until.isoformat()) + + if filters.message_like is not None: + # Escape LIKE special characters in the user-supplied substring so that + # a literal '%' or '_' in the message does not act as a wildcard. + escaped = filters.message_like.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + conditions.append("message LIKE ? ESCAPE '\\'") + params.append(f"%{escaped}%") + + return " AND ".join(conditions) + + +class ActivityLogRepository: + """Purpose-built repository for the ``activity_log`` table. + + Parameters + ---------- + db: + The shared ``Database`` singleton. The migration that creates the + ``activity_log`` table must have been applied before the first call to + :meth:`record`. Construction triggers migration via + ``MigrationRunner`` so users can create the repository directly + without a separate startup step. + """ + + def __init__(self, db: Database) -> None: + self._db = db + # Apply pending migrations (idempotent; most runs are no-ops) + from ledgrab.storage.data_migrations import ALL_MIGRATIONS, MigrationRunner + + MigrationRunner(db).run(ALL_MIGRATIONS) + + # -- Write --------------------------------------------------------------- + + def record(self, entry: ActivityLogEntry) -> None: + """Append *entry* to the log. + + The ``seq`` column is assigned by SQLite (AUTOINCREMENT) — ``entry`` + does not carry a ``seq`` field. + + Caller contract: this must be called from the event-loop thread (or + from a context already serialised through the ``Database`` RLock). + Cross-thread callers must marshal via ``loop.call_soon_threadsafe``; + that is Phase 2's responsibility. + """ + row = entry.to_row() + self._db.execute( + f"INSERT INTO {_TABLE} " + f"(id, ts, category, action, severity, actor, " + f" entity_type, entity_id, entity_name, message, metadata) " + f"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ( + row["id"], + row["ts"], + row["category"], + row["action"], + row["severity"], + row["actor"], + row["entity_type"], + row["entity_id"], + row["entity_name"], + row["message"], + row["metadata"], + ), + ) + + # -- Read ---------------------------------------------------------------- + + def query( + self, + filters: ActivityLogFilters, + *, + before_seq: int | None = None, + limit: int = 50, + ) -> list[ActivityLogEntry]: + """Return the newest matching entries, oldest-first within the page. + + Keyset pagination: pass the smallest ``seq`` seen on the previous page + as *before_seq* to get the next page. Entries are fetched in + ``seq DESC`` order from SQLite and then reversed before returning so + that the caller receives them in chronological order within the page. + + Parameters + ---------- + filters: + Optional filter criteria. + before_seq: + Exclusive upper bound on ``seq``. ``None`` returns the first + (newest) page. + limit: + Maximum number of entries to return. + """ + params: list = [] + keyset = "seq < ?" if before_seq is not None else None + if before_seq is not None: + params.append(before_seq) + + where_fragment = _build_filter_clause(filters, params, extra_where=keyset) + where_clause = f"WHERE {where_fragment}" if where_fragment else "" + params.append(limit) + + sql = ( + f"SELECT seq, id, ts, category, action, severity, actor, " + f"entity_type, entity_id, entity_name, message, metadata " + f"FROM {_TABLE} " + f"{where_clause} " + f"ORDER BY seq DESC " + f"LIMIT ?" + ) + + cursor = self._db.execute(sql, tuple(params)) + rows = cursor.fetchall() + # Reverse to return chronological order within the page + return [ActivityLogEntry.from_row(dict(row)) for row in reversed(rows)] + + def count(self, filters: ActivityLogFilters | None = None) -> int: + """Return the number of entries matching *filters* (or all entries).""" + if filters is None: + filters = ActivityLogFilters() + params: list = [] + where_fragment = _build_filter_clause(filters, params) + where_clause = f"WHERE {where_fragment}" if where_fragment else "" + + sql = f"SELECT COUNT(*) AS cnt FROM {_TABLE} {where_clause}" + cursor = self._db.execute(sql, tuple(params)) + row = cursor.fetchone() + return int(row["cnt"]) + + # -- Maintenance --------------------------------------------------------- + + def prune( + self, + *, + before_ts: datetime | None = None, + max_entries: int | None = None, + ) -> int: + """Delete old / excess entries; return the total rows deleted. + + Both predicates are applied independently: + + 1. Delete all rows where ``ts < before_ts`` (age-based pruning). + 2. Keep only the newest ``max_entries`` rows, deleting the rest + (count-based pruning). + + The two operations run in separate statements so that each can be + independently enabled or disabled. + """ + deleted = 0 + + if before_ts is not None: + cursor = self._db.execute( + f"DELETE FROM {_TABLE} WHERE ts < ?", + (before_ts.isoformat(),), + ) + deleted += cursor.rowcount + + if max_entries is not None and max_entries >= 0: + # Find the seq of the Nth newest row; delete everything older than it. + cursor = self._db.execute( + f"SELECT seq FROM {_TABLE} ORDER BY seq DESC LIMIT 1 OFFSET ?", + (max_entries,), + ) + cutoff_row = cursor.fetchone() + if cutoff_row is not None: + cutoff_seq = cutoff_row["seq"] + cursor = self._db.execute( + f"DELETE FROM {_TABLE} WHERE seq <= ?", + (cutoff_seq,), + ) + deleted += cursor.rowcount + + return deleted + + def clear(self) -> int: + """Delete all entries; return the number of rows deleted.""" + cursor = self._db.execute(f"DELETE FROM {_TABLE}") + return cursor.rowcount + + def get_seq_for_id(self, entry_id: str) -> int | None: + """Return the ``seq`` value for the entry with *entry_id*, or ``None``. + + Used by the API list endpoint to compute the keyset cursor + (``next_before_seq``) from the oldest entry on the current page. + """ + cursor = self._db.execute( + f"SELECT seq FROM {_TABLE} WHERE id = ?", + (entry_id,), + ) + row = cursor.fetchone() + return int(row["seq"]) if row is not None else None + + # -- Export -------------------------------------------------------------- + + def iter_export( + self, + filters: ActivityLogFilters | None = None, + *, + batch_size: int = 1000, + ) -> Iterator[ActivityLogEntry]: + """Yield all matching entries in ascending ``seq`` order. + + Fetches rows in bounded batches (keyset-paginated by ``seq``), holding + the DB lock only for the duration of each ``fetchall()`` and releasing + it before yielding. This prevents a slow/stalled export client from + blocking all other DB operations (record, config writes, etc.) for the + full duration of the stream. + + Memory usage is bounded to ``batch_size`` rows at a time. + """ + if filters is None: + filters = ActivityLogFilters() + + # Keyset cursor: largest seq yielded so far; None means "start from the + # very beginning". We iterate ascending (seq ASC), so each batch uses + # "seq > ?" to advance past the already-yielded rows. + cursor_seq: int | None = None + + while True: + # Build params list: cursor_seq placeholder must come first because + # _build_filter_clause prepends extra_where as the first condition. + params: list = [] + if cursor_seq is not None: + params.append(cursor_seq) + keyset: str | None = "seq > ?" + else: + keyset = None + where_fragment = _build_filter_clause(filters, params, extra_where=keyset) + where_clause = f"WHERE {where_fragment}" if where_fragment else "" + params.append(batch_size) + + sql = ( + f"SELECT seq, id, ts, category, action, severity, actor, " + f"entity_type, entity_id, entity_name, message, metadata " + f"FROM {_TABLE} " + f"{where_clause} " + f"ORDER BY seq ASC " + f"LIMIT ?" + ) + + # Hold the lock only for the bounded fetchall; release before yielding. + with self._db._lock: # noqa: SLF001 — internal access; no public cursor API + rows = self._db._conn.execute(sql, tuple(params)).fetchall() # noqa: SLF001 + + if not rows: + break + + for row in rows: + yield ActivityLogEntry.from_row(dict(row)) + + # The last row has the largest seq in this batch (ORDER BY seq ASC). + cursor_seq = rows[-1]["seq"] + + if len(rows) < batch_size: + # Fewer rows than requested → this was the final batch. + break diff --git a/server/src/ledgrab/storage/data_migrations.py b/server/src/ledgrab/storage/data_migrations.py index b8515d1..ea26de3 100644 --- a/server/src/ledgrab/storage/data_migrations.py +++ b/server/src/ledgrab/storage/data_migrations.py @@ -213,7 +213,57 @@ class StaticToSingleColorMigration(DataMigration): return rows_changed +class AddActivityLogTableMigration(DataMigration): + """Create the ``activity_log`` table and its indexes. + + This is a purely additive migration — it does not touch any existing table. + ``CREATE TABLE / INDEX IF NOT EXISTS`` ensures idempotency if the migration + somehow runs twice (e.g. after a partial restore). + """ + + name = "002_add_activity_log" + + def apply(self, conn: sqlite3.Connection) -> int: + conn.execute( + """ + CREATE TABLE IF NOT EXISTS activity_log ( + seq INTEGER PRIMARY KEY AUTOINCREMENT, + id TEXT UNIQUE NOT NULL, + ts TEXT NOT NULL, + category TEXT NOT NULL, + action TEXT NOT NULL, + severity TEXT NOT NULL, + actor TEXT NOT NULL, + entity_type TEXT, + entity_id TEXT, + entity_name TEXT, + message TEXT NOT NULL, + metadata TEXT NOT NULL DEFAULT '{}' + ) + """ + ) + # Primary read path: newest-first keyset pagination + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_activity_log_ts_seq " + "ON activity_log (ts DESC, seq DESC)" + ) + # Filter indexes + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_activity_log_category " "ON activity_log (category)" + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_activity_log_severity " "ON activity_log (severity)" + ) + conn.execute("CREATE INDEX IF NOT EXISTS idx_activity_log_actor " "ON activity_log (actor)") + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_activity_log_entity " + "ON activity_log (entity_type, entity_id)" + ) + return 0 + + # Master list — ORDER MATTERS. Append new migrations; never reorder. ALL_MIGRATIONS: list[DataMigration] = [ StaticToSingleColorMigration(), + AddActivityLogTableMigration(), ] diff --git a/server/src/ledgrab/storage/game_integration.py b/server/src/ledgrab/storage/game_integration.py index 080b4d9..3669e2b 100644 --- a/server/src/ledgrab/storage/game_integration.py +++ b/server/src/ledgrab/storage/game_integration.py @@ -10,6 +10,48 @@ from dataclasses import dataclass, field from datetime import datetime, timezone from typing import Any, List +from ledgrab.utils import secret_box + +# Keys inside ``adapter_config`` that hold secrets and must be encrypted at +# rest (and decrypted on load). Mirrors the secret_box pattern used by +# storage/http_endpoint.py for its ``auth_token`` field. +_SECRET_CONFIG_KEYS = ("auth_token",) + + +def _encrypt_adapter_config(adapter_config: dict[str, Any]) -> dict[str, Any]: + """Return a copy of *adapter_config* with secret values encrypted. + + ``secret_box.encrypt`` is a no-op on already-encrypted envelopes, so this + is safe to call repeatedly and on configs that were never plaintext. + """ + result = dict(adapter_config) + for key in _SECRET_CONFIG_KEYS: + value = result.get(key) + if isinstance(value, str) and value: + result[key] = secret_box.encrypt(value) + return result + + +def _decrypt_adapter_config(adapter_config: dict[str, Any]) -> dict[str, Any]: + """Return a copy of *adapter_config* with secret values decrypted. + + Migration-safe: ``secret_box.decrypt`` returns the input unchanged when it + is not an encryption envelope, so legacy plaintext rows still load. On a + corrupt/undecryptable envelope, fall back to dropping the value rather than + crashing the whole store load. + """ + result = dict(adapter_config) + for key in _SECRET_CONFIG_KEYS: + value = result.get(key) + if isinstance(value, str) and value: + if secret_box.is_encrypted(value): + try: + result[key] = secret_box.decrypt(value) + except Exception: + result[key] = "" + # else: legacy plaintext — leave as-is (migration-safe read path). + return result + @dataclass class EventMapping: @@ -89,7 +131,8 @@ class GameIntegrationConfig: "name": self.name, "adapter_type": self.adapter_type, "enabled": self.enabled, - "adapter_config": dict(self.adapter_config), + # Encrypt secret values (e.g. webhook auth_token) at rest. + "adapter_config": _encrypt_adapter_config(self.adapter_config), "event_mappings": [m.to_dict() for m in self.event_mappings], "created_at": self.created_at.isoformat(), "updated_at": self.updated_at.isoformat(), @@ -110,7 +153,8 @@ class GameIntegrationConfig: name=data["name"], adapter_type=data["adapter_type"], enabled=data.get("enabled", True), - adapter_config=data.get("adapter_config", {}), + # Decrypt secret values; tolerant of legacy-plaintext rows. + adapter_config=_decrypt_adapter_config(data.get("adapter_config", {})), event_mappings=mappings, created_at=( datetime.fromisoformat(data["created_at"]) diff --git a/server/src/ledgrab/templates/index.html b/server/src/ledgrab/templates/index.html index e269142..ec5dd3a 100644 --- a/server/src/ledgrab/templates/index.html +++ b/server/src/ledgrab/templates/index.html @@ -143,6 +143,7 @@ + @@ -201,6 +202,9 @@ +
+
+