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 @@