diff --git a/INSTALLATION.md b/INSTALLATION.md index ef0e865..3bd92c0 100644 --- a/INSTALLATION.md +++ b/INSTALLATION.md @@ -121,9 +121,9 @@ Screen capture from inside a container requires X11 access. Uncomment `network_m Optional extras: ```bash - pip install ".[camera]" # Webcam capture via OpenCV pip install ".[perf]" # DXCam, BetterCam, WGC (Windows only) pip install ".[notifications]" # OS notification capture + pip install ".[tray]" # System tray icon (Windows only) pip install ".[dev]" # pytest, black, ruff (development) ``` @@ -155,13 +155,13 @@ Option A -- edit the config file: # server/config/default_config.yaml auth: api_keys: - main: "your-secure-key-here" # replace the dev key + dev: "your-secure-key-here" # replace the dev key ``` Option B -- set an environment variable: ```bash -export WLED_AUTH__API_KEYS__main="your-secure-key-here" +export WLED_AUTH__API_KEYS__dev="your-secure-key-here" ``` Generate a random key: @@ -257,6 +257,7 @@ See [`server/.env.example`](server/.env.example) for every available variable wi | `WLED_SERVER__LOG_LEVEL` | `INFO` | `DEBUG`, `INFO`, `WARNING`, `ERROR` | | `WLED_SERVER__CORS_ORIGINS` | `["http://localhost:8080"]` | Allowed CORS origins (JSON array) | | `WLED_AUTH__API_KEYS` | `{"dev":"development-key..."}` | API keys (JSON object) | +| `WLED_STORAGE__DATABASE_FILE` | `data/ledgrab.db` | SQLite database path | | `WLED_MQTT__ENABLED` | `false` | Enable MQTT for HA auto-discovery | | `WLED_MQTT__BROKER_HOST` | `localhost` | MQTT broker address | | `WLED_DEMO` | `false` | Enable demo mode (sandbox with virtual devices) | diff --git a/server/.env.example b/server/.env.example index 0e1c2a6..a9464f7 100644 --- a/server/.env.example +++ b/server/.env.example @@ -12,22 +12,9 @@ # API keys are required. Format: JSON object {"label": "key"}. # WLED_AUTH__API_KEYS={"dev": "development-key-change-in-production"} -# ── Storage paths ─────────────────────────────────────── -# All paths are relative to the server working directory. -# WLED_STORAGE__DEVICES_FILE=data/devices.json -# WLED_STORAGE__TEMPLATES_FILE=data/capture_templates.json -# WLED_STORAGE__POSTPROCESSING_TEMPLATES_FILE=data/postprocessing_templates.json -# WLED_STORAGE__PICTURE_SOURCES_FILE=data/picture_sources.json -# WLED_STORAGE__OUTPUT_TARGETS_FILE=data/output_targets.json -# WLED_STORAGE__PATTERN_TEMPLATES_FILE=data/pattern_templates.json -# WLED_STORAGE__COLOR_STRIP_SOURCES_FILE=data/color_strip_sources.json -# WLED_STORAGE__AUDIO_SOURCES_FILE=data/audio_sources.json -# WLED_STORAGE__AUDIO_TEMPLATES_FILE=data/audio_templates.json -# WLED_STORAGE__VALUE_SOURCES_FILE=data/value_sources.json -# WLED_STORAGE__AUTOMATIONS_FILE=data/automations.json -# WLED_STORAGE__SCENE_PRESETS_FILE=data/scene_presets.json -# WLED_STORAGE__COLOR_STRIP_PROCESSING_TEMPLATES_FILE=data/color_strip_processing_templates.json -# WLED_STORAGE__SYNC_CLOCKS_FILE=data/sync_clocks.json +# ── Storage ──────────────────────────────────────────── +# All data is stored in a single SQLite database. +# WLED_STORAGE__DATABASE_FILE=data/ledgrab.db # ── MQTT (optional) ──────────────────────────────────── # WLED_MQTT__ENABLED=false diff --git a/server/src/wled_controller/static/css/components.css b/server/src/wled_controller/static/css/components.css index df80fa4..17c256f 100644 --- a/server/src/wled_controller/static/css/components.css +++ b/server/src/wled_controller/static/css/components.css @@ -689,6 +689,25 @@ textarea:focus-visible { pointer-events: auto; } +.icon-select-search { + width: calc(100% - 12px); + padding: 8px 12px; + margin: 6px 6px 0; + border: 1px solid var(--border-color); + border-radius: var(--radius); + background: var(--bg-secondary); + color: var(--text-color); + font-size: 0.9rem; + outline: none; + box-sizing: border-box; +} +.icon-select-search::placeholder { + color: var(--text-secondary); +} +.icon-select-search:focus { + border-color: var(--primary-color); +} + .icon-select-grid { display: grid; grid-auto-rows: 1fr; @@ -820,6 +839,7 @@ textarea:focus-visible { /* Override inline columns — use responsive auto-fill */ grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)) !important; } +.icon-select-popup .icon-select-cell.disabled, .type-picker-dialog .icon-select-cell.disabled { opacity: 0.25; pointer-events: none; diff --git a/server/src/wled_controller/static/js/core/icon-select.ts b/server/src/wled_controller/static/js/core/icon-select.ts index c251d6e..51ef0f7 100644 --- a/server/src/wled_controller/static/js/core/icon-select.ts +++ b/server/src/wled_controller/static/js/core/icon-select.ts @@ -58,6 +58,8 @@ export interface IconSelectOpts { onChange?: (value: string) => void; columns?: number; placeholder?: string; + searchable?: boolean; + searchPlaceholder?: string; } export class IconSelect { @@ -66,12 +68,15 @@ export class IconSelect { _onChange: ((value: string) => void) | undefined; _columns: number; _placeholder: string; + _searchable: boolean; + _searchPlaceholder: string; _trigger: HTMLButtonElement; _popup: HTMLDivElement; + _searchInput: HTMLInputElement | null = null; _scrollHandler: (() => void) | null = null; _scrollTargets: (HTMLElement | Window)[] = []; - constructor({ target, items, onChange, columns = 2, placeholder = '' }: IconSelectOpts) { + constructor({ target, items, onChange, columns = 2, placeholder = '', searchable = false, searchPlaceholder = 'Filter…' }: IconSelectOpts) { _ensureGlobalListener(); this._select = target; @@ -79,6 +84,8 @@ export class IconSelect { this._onChange = onChange; this._columns = columns; this._placeholder = placeholder; + this._searchable = searchable; + this._searchPlaceholder = searchPlaceholder; // Hide the native select this._select.style.display = 'none'; @@ -100,6 +107,13 @@ export class IconSelect { this._popup.innerHTML = this._buildGrid(); document.body.appendChild(this._popup); + this._bindPopupEvents(); + + // Sync to current select value + this._syncTrigger(); + } + + _bindPopupEvents() { // Bind item clicks this._popup.querySelectorAll('.icon-select-cell').forEach(cell => { cell.addEventListener('click', () => { @@ -109,20 +123,33 @@ export class IconSelect { }); }); - // Sync to current select value - this._syncTrigger(); + // Bind search input + this._searchInput = this._popup.querySelector('.icon-select-search') as HTMLInputElement | null; + if (this._searchInput) { + this._searchInput.addEventListener('input', () => { + const q = this._searchInput!.value.toLowerCase().trim(); + this._popup.querySelectorAll('.icon-select-cell').forEach(cell => { + const el = cell as HTMLElement; + el.classList.toggle('disabled', !!q && !el.dataset.search!.includes(q)); + }); + }); + } } _buildGrid() { const cells = this._items.map(item => { - return `
+ const search = (item.label + ' ' + (item.desc || '')).toLowerCase(); + return `
${item.icon} ${item.label} ${item.desc ? `${item.desc}` : ''}
`; }).join(''); - return `
${cells}
`; + const searchHTML = this._searchable + ? `` + : ''; + return searchHTML + `
${cells}
`; } _syncTrigger() { @@ -184,6 +211,13 @@ export class IconSelect { this._positionPopup(); this._popup.classList.add('open'); this._addScrollListener(); + if (this._searchInput) { + this._searchInput.value = ''; + this._popup.querySelectorAll('.icon-select-cell').forEach(cell => { + (cell as HTMLElement).classList.remove('disabled'); + }); + requestAnimationFrame(() => desktopFocus(this._searchInput!)); + } } } @@ -233,12 +267,7 @@ export class IconSelect { updateItems(items: IconSelectItem[]) { this._items = items; this._popup.innerHTML = this._buildGrid(); - this._popup.querySelectorAll('.icon-select-cell').forEach(cell => { - cell.addEventListener('click', () => { - this.setValue((cell as HTMLElement).dataset.value!, true); - this._popup.classList.remove('open'); - }); - }); + this._bindPopupEvents(); this._syncTrigger(); } diff --git a/server/src/wled_controller/static/js/features/color-strips.ts b/server/src/wled_controller/static/js/features/color-strips.ts index f9597b6..1b469d4 100644 --- a/server/src/wled_controller/static/js/features/color-strips.ts +++ b/server/src/wled_controller/static/js/features/color-strips.ts @@ -433,7 +433,7 @@ function _ensureEffectPaletteIconSelect() { const items = _buildGradientEntityItems(); _syncSelectOptions(sel, items); if (_effectPaletteIconSelect) { _effectPaletteIconSelect.updateItems(items); return; } - _effectPaletteIconSelect = new IconSelect({ target: sel, items, columns: 2 }); + _effectPaletteIconSelect = new IconSelect({ target: sel, items, columns: 2, searchable: true, searchPlaceholder: t('palette.search') }); } function _ensureGradientEasingIconSelect() { @@ -468,7 +468,7 @@ function _ensureAudioPaletteIconSelect() { const items = _buildGradientEntityItems(); _syncSelectOptions(sel, items); if (_audioPaletteIconSelect) { _audioPaletteIconSelect.updateItems(items); return; } - _audioPaletteIconSelect = new IconSelect({ target: sel, items, columns: 2 }); + _audioPaletteIconSelect = new IconSelect({ target: sel, items, columns: 2, searchable: true, searchPlaceholder: t('palette.search') }); } function _ensureAudioVizIconSelect() { @@ -507,7 +507,7 @@ function _ensureGradientPresetIconSelect() { const items = _buildGradientEntityItems(); _syncSelectOptions(sel, items); if (_gradientPresetIconSelect) { _gradientPresetIconSelect.updateItems(items); return; } - _gradientPresetIconSelect = new IconSelect({ target: sel, items, columns: 3 }); + _gradientPresetIconSelect = new IconSelect({ target: sel, items, columns: 3, searchable: true, searchPlaceholder: t('palette.search') }); } /** Rebuild the gradient picker after entity changes. */ @@ -1728,12 +1728,14 @@ export async function showCSSEditor(cssId: any = null, cloneData: any = null, pr } await _populateFromCSS(css); - (document.getElementById('css-editor-title') as HTMLElement).innerHTML = `${ICON_FILM} ${t('color_strip.edit')}`; + const editIcon = getColorStripIcon(css.source_type); + (document.getElementById('css-editor-title') as HTMLElement).innerHTML = `${editIcon} ${t('color_strip.edit')} ${t(`color_strip.type.${css.source_type}`)}`; } else if (cloneData) { (document.getElementById('css-editor-id') as HTMLInputElement).value = ''; (document.getElementById('css-editor-name') as HTMLInputElement).value = (cloneData.name || '') + ' (Copy)'; await _populateFromCSS(cloneData); - (document.getElementById('css-editor-title') as HTMLElement).innerHTML = `${ICON_FILM} ${t('color_strip.add')}`; + const cloneIcon = getColorStripIcon(cloneData.source_type); + (document.getElementById('css-editor-title') as HTMLElement).innerHTML = `${cloneIcon} ${t('color_strip.add')} ${t(`color_strip.type.${cloneData.source_type}`)}`; } else { (document.getElementById('css-editor-id') as HTMLInputElement).value = ''; (document.getElementById('css-editor-name') as HTMLInputElement).value = ''; @@ -1748,7 +1750,7 @@ export async function showCSSEditor(cssId: any = null, cloneData: any = null, pr } const typeIcon = getColorStripIcon(effectiveType); - (document.getElementById('css-editor-title') as HTMLElement).innerHTML = `${typeIcon} ${t('color_strip.add')}: ${t(`color_strip.type.${effectiveType}`)}`; + (document.getElementById('css-editor-title') as HTMLElement).innerHTML = `${typeIcon} ${t('color_strip.add')} ${t(`color_strip.type.${effectiveType}`)}`; _autoGenerateCSSName(); } diff --git a/server/src/wled_controller/static/locales/en.json b/server/src/wled_controller/static/locales/en.json index 913cdd1..dd2dbdf 100644 --- a/server/src/wled_controller/static/locales/en.json +++ b/server/src/wled_controller/static/locales/en.json @@ -911,8 +911,8 @@ "aria.next": "Next", "aria.hint": "Show hint", "color_strip.select_type": "Select Color Strip Type", - "color_strip.add": "Add Color Strip Source", - "color_strip.edit": "Edit Color Strip Source", + "color_strip.add": "Add", + "color_strip.edit": "Edit", "color_strip.name": "Name:", "color_strip.name.placeholder": "Wall Strip", "color_strip.picture_source": "Picture Source:", diff --git a/server/src/wled_controller/static/locales/ru.json b/server/src/wled_controller/static/locales/ru.json index e91d2f6..1085caf 100644 --- a/server/src/wled_controller/static/locales/ru.json +++ b/server/src/wled_controller/static/locales/ru.json @@ -911,8 +911,8 @@ "aria.next": "Вперёд", "aria.hint": "Показать подсказку", "color_strip.select_type": "Выберите тип цветовой полосы", - "color_strip.add": "Добавить источник цветовой полосы", - "color_strip.edit": "Редактировать источник цветовой полосы", + "color_strip.add": "Добавить", + "color_strip.edit": "Редактировать", "color_strip.name": "Название:", "color_strip.name.placeholder": "Настенная полоса", "color_strip.picture_source": "Источник изображения:", diff --git a/server/src/wled_controller/static/locales/zh.json b/server/src/wled_controller/static/locales/zh.json index 2f08486..5c662e8 100644 --- a/server/src/wled_controller/static/locales/zh.json +++ b/server/src/wled_controller/static/locales/zh.json @@ -911,8 +911,8 @@ "aria.next": "下一个", "aria.hint": "显示提示", "color_strip.select_type": "选择色带类型", - "color_strip.add": "添加色带源", - "color_strip.edit": "编辑色带源", + "color_strip.add": "添加", + "color_strip.edit": "编辑", "color_strip.name": "名称:", "color_strip.name.placeholder": "墙壁灯带", "color_strip.picture_source": "图片源:", diff --git a/server/src/wled_controller/storage/gradient_store.py b/server/src/wled_controller/storage/gradient_store.py index 1518f69..2da2786 100644 --- a/server/src/wled_controller/storage/gradient_store.py +++ b/server/src/wled_controller/storage/gradient_store.py @@ -1,8 +1,8 @@ """Gradient storage with built-in seeding. -Provides CRUD for gradient entities. On first run (empty/missing data), -seeds 8 built-in gradients matching the legacy hardcoded palettes. -Built-in gradients are read-only and cannot be deleted or modified. +Provides CRUD for gradient entities. On startup, seeds any missing +built-in gradients. Built-in gradients are read-only and cannot be +deleted or modified. """ import uuid @@ -36,6 +36,16 @@ _BUILTIN_DEFS = { (0.75, 255, 192, 64), (1.0, 255, 255, 192), ], "ice": [(0, 0, 0, 64), (0.33, 0, 64, 192), (0.66, 128, 192, 255), (1.0, 240, 248, 255)], + "warm": [(0, 255, 255, 80), (0.33, 255, 160, 0), (0.67, 255, 60, 0), (1.0, 160, 0, 0)], + "cool": [(0, 0, 255, 200), (0.33, 0, 120, 255), (0.67, 60, 0, 255), (1.0, 120, 0, 180)], + "neon": [ + (0, 255, 0, 200), (0.25, 0, 255, 255), (0.5, 0, 255, 50), + (0.75, 255, 255, 0), (1.0, 255, 0, 100), + ], + "pastel": [ + (0, 255, 180, 180), (0.2, 255, 220, 160), (0.4, 255, 255, 180), + (0.6, 180, 255, 200), (0.8, 180, 200, 255), (1.0, 220, 180, 255), + ], } @@ -50,14 +60,16 @@ class GradientStore(BaseSqliteStore[Gradient]): def __init__(self, db: Database): super().__init__(db, Gradient.from_dict) - if not self._items: - self._seed_builtins() + self._seed_missing_builtins() - def _seed_builtins(self) -> None: - """Create the 8 built-in gradients on first run.""" + def _seed_missing_builtins(self) -> None: + """Seed any built-in gradients not yet in the store.""" now = datetime.now(timezone.utc) + added = 0 for name, tuples in _BUILTIN_DEFS.items(): gid = f"gr_builtin_{name}" + if gid in self._items: + continue gradient = Gradient( id=gid, name=name.capitalize(), @@ -69,7 +81,9 @@ class GradientStore(BaseSqliteStore[Gradient]): ) self._items[gid] = gradient self._save_item(gid, gradient) - logger.info(f"Seeded {len(_BUILTIN_DEFS)} built-in gradients") + added += 1 + if added: + logger.info(f"Seeded {added} new built-in gradients") # Aliases get_all_gradients = BaseSqliteStore.get_all diff --git a/server/src/wled_controller/templates/modals/css-editor.html b/server/src/wled_controller/templates/modals/css-editor.html index 9378ca8..44edebe 100644 --- a/server/src/wled_controller/templates/modals/css-editor.html +++ b/server/src/wled_controller/templates/modals/css-editor.html @@ -2,7 +2,7 @@