diff --git a/TODO.md b/TODO.md deleted file mode 100644 index 6b39bf7..0000000 --- a/TODO.md +++ /dev/null @@ -1,231 +0,0 @@ -# New Value Source Types + Filter Support - -## Feature 1: HA Value Source (`ha_entity`) - -A value source that reads numeric values from a Home Assistant entity's state or attribute. Allows binding any scalar property in the system to a live HA sensor/entity value. - -### Configuration -- `ha_source_id: str` — HA connection entity (EntitySelect picker) -- `entity_id: str` — HA entity (EntitySelect picker, populated from `/api/v1/home-assistant/sources/{id}/entities`) -- `attribute: str` — optional attribute name (text input or dropdown populated from entity attributes) -- `min_ha_value: float` — raw HA value corresponding to output 0.0 -- `max_ha_value: float` — raw HA value corresponding to output 1.0 -- `smoothing: float` — EMA smoothing factor (0..1) -- `return_type: "float"` — always float - -### Backend - -- [ ] **Storage model** — `HAEntityValueSource` subclass in `storage/value_source.py` - - Fields: `ha_source_id`, `entity_id`, `attribute`, `min_ha_value`, `max_ha_value`, `smoothing` - - Register in `_VALUE_SOURCE_MAP` as `"ha_entity"` - - `to_dict()` / `from_dict()` / `_parse_common_fields()` - -- [ ] **Store** — add `"ha_entity"` case in `ValueSourceStore.create_source()` and `update_source()` - - Validate: `ha_source_id` must be non-empty, `entity_id` must be non-empty - -- [ ] **API schemas** — `HAEntityValueSourceCreate`, `HAEntityValueSourceResponse` in `api/schemas/value_sources.py` - - Add to `ValueSourceCreate` / `ValueSourceResponse` discriminated unions - - Fields: `ha_source_id`, `entity_id`, `attribute` (optional), `min_ha_value`, `max_ha_value`, `smoothing` - -- [ ] **API routes** — add `HAEntityValueSource` → response builder in `_RESPONSE_MAP` - -- [ ] **Stream** — `HAEntityValueStream` in `core/processing/value_stream.py` - - `start()`: acquire HA runtime via `ha_manager.acquire(ha_source_id)` - - `get_value()`: read `ha_manager.get_state(ha_source_id, entity_id)` → extract state or attribute → clamp/normalize to [0,1] via min/max range → apply EMA smoothing - - `stop()`: release HA runtime - - `update_source()`: hot-update parameters - - Add to `ValueStreamManager._create_stream()` - -### Frontend - -- [ ] **TypeScript type** — `HAEntityValueSource` interface in `types.ts` - - `source_type: 'ha_entity'`, `return_type: 'float'` - - Fields: `ha_source_id`, `entity_id`, `attribute`, `min_ha_value`, `max_ha_value`, `smoothing` - - Add to `ValueSourceType` union and `ValueSource` union - -- [ ] **Icon** — add `ha_entity: _svg(P.home)` to `_valueSourceTypeIcons` in `icons.ts` - -- [ ] **i18n** — add keys in `en.json`: - - `value_source.type.ha_entity`: "Home Assistant Entity" - - `value_source.type.ha_entity.desc`: "Reads value from a Home Assistant sensor or entity attribute" - - `value_source.ha_source`: "HA Connection:" - - `value_source.entity_id`: "Entity:" - - `value_source.attribute`: "Attribute (optional):" - - `value_source.min_ha_value`: "Min HA Value:" - - `value_source.max_ha_value`: "Max HA Value:" - -- [ ] **Editor modal** — add `ha_entity` section to `value-source-editor.html` - - HA connection selector (EntitySelect from HA sources cache) - - Entity selector (EntitySelect populated from HA entities endpoint) - - Attribute text input (optional) - - Min/Max HA value range inputs - - Smoothing slider - -- [ ] **Editor logic** — add `ha_entity` handler in `value-sources.ts` - - `onValueSourceTypeChange()`: show/hide ha_entity section - - `_typeHandlers['ha_entity']`: load/reset/getPayload - - EntitySelect for HA source + EntitySelect for entity (refreshes when HA source changes) - - Auto-name: "{entity_friendly_name}" or "{entity_id}" - -- [ ] **Card renderer** — show HA source link + entity ID + attribute (if set) + range -- [ ] **VS_TYPE_KEYS** — add `'ha_entity'` to the array - ---- - -## Feature 2: Lerp Color Value Source (`gradient_map`) - -A color value source that maps a numeric value source's output through a color gradient. Given a float value source (0..1), interpolates the color at that position in a user-defined gradient. - -### Configuration -- `value_source_id: str` — reference to a float-returning value source (EntitySelect) -- `stops: List[ColorStop]` — gradient color stops `[{position: float, color: [R,G,B]}]` (reuse existing `ColorStop` model from color strip sources) -- `easing: str` — interpolation mode: "linear", "step" (reuse existing easing modes) -- `return_type: "color"` — always color - -### Backend - -- [ ] **Storage model** — `GradientMapValueSource` subclass in `storage/value_source.py` - - Fields: `value_source_id`, `stops` (list of dicts with `position` + `color`), `easing` - - Register in `_VALUE_SOURCE_MAP` as `"gradient_map"` - -- [ ] **Store** — add `"gradient_map"` case in `create_source()` / `update_source()` - - Validate: at least 2 stops, `value_source_id` non-empty - -- [ ] **API schemas** — `GradientMapValueSourceCreate`, `GradientMapValueSourceResponse` - - Reuse `ColorStop` schema from color_strip_sources schemas (or define minimal version) - - Add to discriminated unions - -- [ ] **API routes** — add to `_RESPONSE_MAP` - -- [ ] **Stream** — `GradientMapValueStream` in `value_stream.py` - - `start()`: acquire the referenced value stream via `ValueStreamManager.acquire(value_source_id)` - - `get_value()`: return BT.601 luminance of current color - - `get_color()`: call `inner_stream.get_value()` → interpolate through gradient stops → return RGB tuple - - Reuse `_compute_gradient_colors()` logic from color_strip_stream.py (or a shared helper for single-point interpolation) - - `stop()`: release inner value stream - - `update_source()`: hot-update stops/easing, re-acquire if value_source_id changed - -### Frontend - -- [ ] **TypeScript type** — `GradientMapValueSource` interface - - `source_type: 'gradient_map'`, `return_type: 'color'` - - Fields: `value_source_id`, `stops: ColorStop[]`, `easing` - -- [ ] **Icon** — add `gradient_map: _svg(P.rainbow)` to `_valueSourceTypeIcons` - -- [ ] **i18n** — add keys: - - `value_source.type.gradient_map`: "Gradient Map" - - `value_source.type.gradient_map.desc`: "Maps a numeric value through a color gradient" - - `value_source.input_source`: "Input Value Source:" - - `value_source.gradient_stops`: "Gradient:" - - `value_source.easing`: "Interpolation:" - -- [ ] **Editor modal** — add `gradient_map` section - - Value source selector (EntitySelect from float value sources) - - Gradient stop editor (reuse gradient stop UI from CSS editor if possible, or build minimal version: list of position + color picker rows) - - Easing selector (IconSelect: linear, step) - - Live gradient preview bar (CSS linear-gradient from stops) - -- [ ] **Editor logic** — `_typeHandlers['gradient_map']`: load/reset/getPayload -- [ ] **Card renderer** — CSS gradient preview bar + input source link + stop count -- [ ] **VS_TYPE_KEYS** — add `'gradient_map'` - ---- - -## Feature 3: CSS Extraction Color Value Source (`css_extract`) - -A color value source that extracts a single color from a color strip source by averaging a range of LEDs. Useful for deriving a single color signal from an existing color strip. - -### Configuration -- `color_strip_source_id: str` — reference to a color strip source (EntitySelect) -- `led_start: int` — start of LED range (0-based, optional, default 0) -- `led_end: int` — end of LED range (exclusive, optional, default -1 = whole strip) -- `return_type: "color"` — always color - -### Backend - -- [ ] **Storage model** — `CSSExtractValueSource` subclass in `storage/value_source.py` - - Fields: `color_strip_source_id`, `led_start`, `led_end` - - Register as `"css_extract"` - -- [ ] **Store** — add `"css_extract"` case in `create_source()` / `update_source()` - - Validate: `color_strip_source_id` non-empty - -- [ ] **API schemas** — `CSSExtractValueSourceCreate`, `CSSExtractValueSourceResponse` - - Add to discriminated unions - -- [ ] **API routes** — add to `_RESPONSE_MAP` - -- [ ] **Stream** — `CSSExtractValueStream` in `value_stream.py` - - `start()`: acquire color strip stream via `ColorStripStreamManager.acquire(color_strip_source_id, led_count=needed)` - - `get_color()`: read strip colors → average the specified LED range → return single RGB tuple - - `get_value()`: BT.601 luminance of extracted color - - `stop()`: release color strip stream - - `update_source()`: hot-update range, re-acquire if source changed - - **Note**: Needs access to `ColorStripStreamManager` — may need to inject it into `ValueStreamManager` or pass via constructor - -### Frontend - -- [ ] **TypeScript type** — `CSSExtractValueSource` interface - - `source_type: 'css_extract'`, `return_type: 'color'` - - Fields: `color_strip_source_id`, `led_start`, `led_end` - -- [ ] **Icon** — add `css_extract: _svg(P.eyedropper)` (or `P.pipette` if available, else `P.palette`) - -- [ ] **i18n** — add keys: - - `value_source.type.css_extract`: "Strip Color Extract" - - `value_source.type.css_extract.desc`: "Extracts a single color from a color strip source" - - `value_source.color_strip_source`: "Color Strip Source:" - - `value_source.led_start`: "LED Start:" - - `value_source.led_end`: "LED End (-1 = all):" - -- [ ] **Editor modal** — add `css_extract` section - - Color strip source selector (EntitySelect from color strip sources cache) - - LED start/end numeric inputs - - Optional: live color preview swatch - -- [ ] **Editor logic** — `_typeHandlers['css_extract']`: load/reset/getPayload -- [ ] **Card renderer** — color strip source link + LED range badge -- [ ] **VS_TYPE_KEYS** — add `'css_extract'` - ---- - -## Feature 4: Value Source Type Filter in Icon Grid - -Add a filter/category system to the value source type IconSelect so users can filter by return type or category. - -### Implementation - -- [ ] **Add filter tabs** above the value source type icon grid in the editor modal - - "All" (default) — show all types - - "Float" — show float-returning types: static, animated, audio, adaptive_time, adaptive_scene, daylight, ha_entity - - "Color" — show color-returning types: static_color, animated_color, adaptive_time_color, gradient_map, css_extract - -- [ ] **IconSelect enhancement** — either: - - Option A: Add `groups` support to IconSelect (items grouped by category with filter tabs) - - Option B: Filter `VS_TYPE_KEYS` before building items, with toggle buttons above the grid - - Decision: Option B is simpler and follows existing patterns — add filter buttons that rebuild the icon grid - -- [ ] **i18n** — add keys: - - `value_source.filter.all`: "All" - - `value_source.filter.float`: "Float" - - `value_source.filter.color`: "Color" - ---- - -## Implementation Order - -1. **Feature 4** (filter) — smallest, unblocks better UX for the growing type list -2. **Feature 1** (ha_entity) — standalone float type, no cross-dependencies -3. **Feature 3** (css_extract) — needs ColorStripStreamManager injection -4. **Feature 2** (gradient_map) — needs float VS reference + gradient UI - -## Cross-Cutting Concerns - -- All new types need entries in `_VALUE_SOURCE_MAP` (backend) and `VS_TYPE_KEYS` (frontend) -- All new types need `_RESPONSE_MAP` entries in routes -- All new types need `ValueStreamManager._create_stream()` factory case -- All new types need icon in `_valueSourceTypeIcons` -- All new types need i18n keys in `en.json` (and `ru.json`, `zh.json` — can defer translations) -- `ValueSourceStore` referential integrity check on delete should verify new references (ha_entity → ha_source, gradient_map → value_source, css_extract → color_strip_source) -- Graph editor: new edge types for ha_entity → HA source node, gradient_map → value source node, css_extract → color strip node diff --git a/plans/game-integration/CONTEXT.md b/plans/game-integration/CONTEXT.md new file mode 100644 index 0000000..551eb18 --- /dev/null +++ b/plans/game-integration/CONTEXT.md @@ -0,0 +1,85 @@ +# Feature Context: Game Integration + +## Configuration +- **Development mode:** Automated +- **Execution mode:** Orchestrator +- **Strategy:** Big Bang +- **Build (frontend):** `cd server && npm run build` +- **Test:** `cd server && py -3.13 -m pytest tests/ --no-cov -q` +- **Lint:** `cd server && ruff check src/ tests/ --fix` +- **TypeScript:** `cd server && npx tsc --noEmit` + +## Architecture Decisions + +### Not a Capture Engine +Game events are discrete data points, not pixel buffers. The integration lives as a separate `core/game_integration/` module with two output channels into existing pipelines: +1. `GameEventColorStripSource/Stream` — renders LED effects on game events +2. `GameEventValueSource` — exposes game metrics as 0.0-1.0 scalars for parameter binding + +### Standardized Event Vocabulary +All adapters map game-specific data into universal event categories (health, kill, death, round_start, etc.). Users configure effects against categories, not game-specific IDs. This means a "health < 30% → flash red" config works across CS2, LoL, and any future game. + +### Three Adapter Tiers +1. **Built-in** — CS2 GSI, LoL Live Client, Dota 2 GSI, generic webhook (ship with app) +2. **Community YAML** — declarative adapter files mapping JSON paths to standard events (no code) +3. **Generic webhook** — fallback with UI-guided JSON path mapping + +### Event Types +- **Continuous** (health, armor, mana, ammo) → best for ValueSource (drive brightness/speed/color) +- **Trigger** (kill, death, round_start, bomb_planted) → best for ColorStripStream (fire effects) + +## Tech Stack Context + +### Backend Patterns (from codebase analysis) +- **Entity pattern**: dataclass model (`storage/`) + JSON/SQLite store (`storage/*_store.py`) + Pydantic schemas (`api/schemas/`) + routes (`api/routes/`) +- **Store base**: `BaseSqliteStore[T]` with write-through cache, RLock, `_check_name_unique()` +- **ID generation**: `f"{prefix}_{uuid.uuid4().hex[:8]}"` +- **Events**: `fire_entity_event(entity_type, action, entity_id)` for UI invalidation +- **ColorStripSource**: inheritance with `_SOURCE_TYPE_MAP` registry, `to_dict()`/`from_dict()`/`create_from_kwargs()`/`apply_update()` +- **ColorStripStream**: `_SIMPLE_STREAM_MAP` in `color_strip_stream_manager.py` +- **ValueSource**: similar inheritance with `_VALUE_SOURCE_MAP` registry +- **NotificationColorStripStream**: closest analog — event-driven, deque + lock, 30 FPS render loop, double-buffered output +- **Bindable**: `BindableFloat`/`BindableColor` for value source parameter binding +- **Dependencies**: `init_dependencies()` in `dependencies.py`, FastAPI `Depends()` +- **STORE_MAP**: in `api/routes/system.py` for backup/restore + +### Frontend Patterns +- Vanilla TypeScript modules, no framework +- `CardSection` for entity lists with reconciliation +- `Modal` base class with `snapshotValues()` for dirty check +- `TreeNav` for sidebar navigation in Streams tab +- `DataCache` for state management +- `fetchWithAuth()` for all API calls +- `t('key')` for i18n, keys in en.json/ru.json/zh.json +- `fire_entity_event` → frontend listens for cache invalidation +- Icons from `core/icons.ts` (SVG paths), NEVER emoji +- `IconSelect` for predefined item grids, `EntitySelect` for entity references + +## Current State +Feature not yet started. Branch created, plan files written. + +## Temporary Workarounds +None yet. + +## Cross-Phase Dependencies +- Phase 2 depends on Phase 1 (EventBus, AdapterRegistry) +- Phase 3 depends on Phase 1 (GameAdapter ABC, MappingAdapter) +- Phase 4 depends on Phase 1 (EventBus) and Phase 2 (GameIntegrationStore for config) +- Phase 5 depends on Phase 1 (EventBus) and Phase 2 (GameIntegrationStore) +- Phase 6 depends on Phase 2 (API endpoints) and Phase 3 (adapter metadata) +- Phase 7 depends on Phase 4 (CSS source type) and Phase 5 (value source type) +- Phase 8 depends on all prior phases +- **Phases 4 and 5 are independent** — can run in parallel + +## Deferred Work +None yet. + +## Failed Approaches +None yet. + +## Review Findings Log +None yet. + +## Phase Execution Log +| Phase | Agent Used | Test Writer | Parallel | Notes | +|-------|-----------|-------------|----------|-------| diff --git a/plans/game-integration/PLAN.md b/plans/game-integration/PLAN.md new file mode 100644 index 0000000..f59f42e --- /dev/null +++ b/plans/game-integration/PLAN.md @@ -0,0 +1,53 @@ +# Feature: Game Integration + +**Branch:** `feature/game-integration` +**Base branch:** `master` +**Created:** 2026-03-30 +**Status:** 🟡 In Progress +**Strategy:** Big Bang +**Mode:** Automated +**Execution:** Orchestrator + +## Summary + +A system that receives real-time events from games (CS2, LoL, Dota 2, etc.) and drives LED effects through the existing color strip and value source pipelines. Uses a standardized event vocabulary so users configure effects against universal categories (health, kill, death) rather than game-specific IDs. Supports built-in adapters, community YAML adapter files, and a generic webhook fallback. + +## Build & Test Commands +- **Build (frontend):** `cd server && npm run build` +- **Test:** `cd server && py -3.13 -m pytest tests/ --no-cov -q` +- **Lint:** `cd server && ruff check src/ tests/ --fix` +- **TypeScript:** `cd server && npx tsc --noEmit` + +## Phases + +- [x] Phase 1: Core Event Bus & Adapter Framework [backend] → [subplan](./phase-1-event-bus.md) +- [x] Phase 2: Storage & API — Game Integration Configs [backend] → [subplan](./phase-2-storage-api.md) +- [x] Phase 3: Built-in Game Adapters [backend] → [subplan](./phase-3-adapters.md) +- [x] Phase 4: GameEventColorStripStream [backend] → [subplan](./phase-4-css-stream.md) +- [x] Phase 5: GameEventValueSource [backend] → [subplan](./phase-5-value-source.md) +- [x] Phase 6: Frontend — Game Integration Management UI [frontend] → [subplan](./phase-6-frontend-management.md) +- [x] Phase 7: Frontend — ColorStrip & ValueSource Game Bindings [frontend] → [subplan](./phase-7-frontend-bindings.md) +- [x] Phase 8: Effect Presets & Polish [fullstack] → [subplan](./phase-8-presets-polish.md) + +## Phase Progress Log + +| Phase | Domain | Status | Review | Build | Committed | +|-------|--------|--------|--------|-------|-----------| +| Phase 1: Event Bus & Adapter Framework | backend | ✅ Done | ✅ | ⬜ | ⬜ | +| Phase 2: Storage & API | backend | ✅ Done | ✅ | ⬜ | ⬜ | +| Phase 3: Built-in Adapters | backend | ✅ Done | ✅ | ⬜ | ⬜ | +| Phase 4: CSS Stream | backend | ✅ Done | ✅ | ⬜ | ⬜ | +| Phase 5: Value Source | backend | ✅ Done | ✅ | ⬜ | ⬜ | +| Phase 6: Frontend Management UI | frontend | ✅ Done | ✅ | ⬜ | ⬜ | +| Phase 7: Frontend Bindings | frontend | ✅ Done | ✅ | ⬜ | ⬜ | +| Phase 8: Presets & Polish | fullstack | ✅ Done | ✅ | ✅ | ⬜ | + +## Parallel Phases + +Phases 4 and 5 are independent (no shared files) — can run in parallel. + +## Final Review +- [ ] Comprehensive code review +- [ ] Full build passes +- [ ] Full test suite passes +- [ ] Merged to `master` diff --git a/plans/game-integration/phase-1-event-bus.md b/plans/game-integration/phase-1-event-bus.md new file mode 100644 index 0000000..598fbf5 --- /dev/null +++ b/plans/game-integration/phase-1-event-bus.md @@ -0,0 +1,107 @@ +# Phase 1: Core Event Bus & Adapter Framework + +**Status:** ✅ Complete +**Parent plan:** [PLAN.md](./PLAN.md) +**Domain:** backend + +## Objective +Build the foundational event infrastructure: the standardized event model, in-process pub/sub bus, adapter ABC, adapter registry, and the YAML-driven mapping adapter. This phase creates the core abstractions that all subsequent phases depend on. + +## Tasks + +- [x] Task 1: Create `core/game_integration/__init__.py` package +- [x] Task 2: Define standardized event vocabulary as an enum/constant module (`core/game_integration/events.py`) + - Categories: health, armor, shield, mana, energy, ammo, gold, fuel, speed (continuous) + - Categories: kill, death, assist, damage_taken, damage_dealt (combat triggers) + - Categories: match_start, match_end, round_start, round_end (match flow triggers) + - Categories: objective_captured, objective_lost, objective_progress (objective) + - Categories: stunned, blinded, buffed, debuffed (status effects) + - Categories: team_a, team_b (team affiliation) + - Each event type has metadata: display name, category, value_type (continuous/trigger), default_range (min, max) +- [x] Task 3: Create `GameEvent` frozen dataclass (`core/game_integration/events.py`) + - Fields: `adapter_id`, `event_type` (str from vocabulary), `value` (float 0.0-1.0), `raw_data` (dict), `timestamp` (float, monotonic) +- [x] Task 4: Create `GameEventBus` (`core/game_integration/event_bus.py`) + - Thread-safe pub/sub with `threading.Lock` + - `publish(event: GameEvent)` — dispatches to matching subscribers + - `subscribe(event_type: str, callback)` → returns subscription ID + - `subscribe_all(callback)` → receives all events (for diagnostics/live monitor) + - `unsubscribe(subscription_id)` + - `get_recent_events(limit=50)` → returns recent events deque for diagnostics + - `get_stats()` → event counts per type, last event timestamp + - Use `collections.deque(maxlen=100)` for recent events +- [x] Task 5: Create `GameAdapter` ABC (`core/game_integration/base_adapter.py`) + - Class attributes: `ADAPTER_TYPE`, `DISPLAY_NAME`, `GAME_NAME`, `SUPPORTED_EVENTS` (list of event type strings) + - `parse_payload(cls, payload: dict, adapter_config: dict, prev_state: dict) -> tuple[list[GameEvent], dict]` — returns events + updated state (for diff detection) + - `validate_auth(cls, headers: dict, payload: dict, adapter_config: dict) -> bool` + - `get_config_schema(cls) -> dict` — returns JSON schema for adapter-specific config fields (for UI auto-generation) + - `get_setup_instructions(cls) -> str` — returns markdown setup guide for the game +- [x] Task 6: Create `AdapterRegistry` (`core/game_integration/adapter_registry.py`) + - `register(adapter_class)`, `get_adapter(adapter_type)`, `get_all_adapters()`, `get_available_adapters()` + - Follow `EngineRegistry` pattern +- [x] Task 7: Create `MappingAdapter` (`core/game_integration/mapping_adapter.py`) + - Parses community YAML adapter files into a generic adapter instance + - YAML schema: adapter name, game name, protocol (webhook/poll), mappings list + - Each mapping: `source_path` (JSONPath-like), `event` (standard type), `min`/`max` (for continuous), `trigger` mode (on_change/on_increase/on_decrease/on_value) + - `load_adapter_from_yaml(path) -> GameAdapter` — factory function + - `validate_adapter_yaml(data: dict) -> list[str]` — returns validation errors +- [x] Task 8: Create `core/game_integration/adapters/__init__.py` package (empty, for Phase 3) +- [x] Task 9: Write unit tests for GameEventBus (publish, subscribe, unsubscribe, thread safety) +- [x] Task 10: Write unit tests for AdapterRegistry (register, get, duplicates) +- [x] Task 11: Write unit tests for MappingAdapter (YAML parsing, payload translation, validation) + +## Files to Modify/Create +- `server/src/wled_controller/core/game_integration/__init__.py` — package init, re-exports +- `server/src/wled_controller/core/game_integration/events.py` — GameEvent dataclass + event vocabulary +- `server/src/wled_controller/core/game_integration/event_bus.py` — GameEventBus +- `server/src/wled_controller/core/game_integration/base_adapter.py` — GameAdapter ABC +- `server/src/wled_controller/core/game_integration/adapter_registry.py` — AdapterRegistry +- `server/src/wled_controller/core/game_integration/mapping_adapter.py` — MappingAdapter + YAML loader +- `server/src/wled_controller/core/game_integration/adapters/__init__.py` — empty package +- `server/tests/core/test_game_event_bus.py` — EventBus tests +- `server/tests/core/test_adapter_registry.py` — Registry tests +- `server/tests/core/test_mapping_adapter.py` — MappingAdapter tests + +## Acceptance Criteria +- GameEvent is frozen/immutable with all required fields +- Event vocabulary covers all standard categories with metadata +- EventBus correctly dispatches events to type-specific and wildcard subscribers +- EventBus is thread-safe (concurrent publish/subscribe doesn't crash) +- AdapterRegistry follows EngineRegistry pattern faithfully +- MappingAdapter can load a YAML file and translate a JSON payload into GameEvents +- MappingAdapter validates YAML schema and reports errors clearly +- All tests pass + +## Notes +- Use `time.monotonic()` for timestamps (not `time.time()`) — monotonic is immune to clock adjustments +- Keep the event vocabulary extensible — new types should just be new entries, no code changes +- The MappingAdapter is key for scaling to many games without writing Python code per game +- `prev_state` parameter on `parse_payload` enables diff-based trigger detection (e.g. CS2 kills counter) + +## Review Checklist +- [x] All tasks completed +- [x] Code follows project conventions (PEP 8, type annotations, immutability) +- [x] No unintended side effects +- [x] Tests pass (new + existing) + +## Handoff to Next Phase + +**Completed:** All 11 tasks implemented and tested. 55 tests pass, 0 failures. Ruff clean. + +### What was built +- `events.py` — `GameEvent` frozen dataclass + 23-type event vocabulary with `EventTypeMetadata` (category, value_type, default_range). Helper functions: `get_event_vocabulary()`, `get_event_metadata()`, `is_known_event_type()`. +- `event_bus.py` — `GameEventBus` with thread-safe pub/sub. Type-specific and wildcard subscriptions, `deque(maxlen=100)` for recent events, per-type event counts. Callbacks invoked outside lock to prevent deadlocks. +- `base_adapter.py` — `GameAdapter` ABC with `ADAPTER_TYPE`/`DISPLAY_NAME`/`GAME_NAME`/`SUPPORTED_EVENTS` class vars. Abstract methods: `parse_payload()`, `validate_auth()`. Default implementations: `get_config_schema()`, `get_setup_instructions()`. +- `adapter_registry.py` — `AdapterRegistry` following `EngineRegistry` pattern (class-level dict, register/get/list/clear). +- `mapping_adapter.py` — `MappingAdapter` (concrete, instance-based) + `load_adapter_from_yaml()` factory + `validate_adapter_yaml()` validator. Supports dot-notation JSON paths, 4 trigger modes (on_change/on_increase/on_decrease/on_value), value normalization to 0.0-1.0, header-based auth. +- `adapters/__init__.py` — empty package ready for Phase 3 built-in adapters. +- `__init__.py` — re-exports all public API. + +### Key design decisions +- `MappingAdapter` is instance-based (not classmethod-based like built-in adapters) because each YAML file creates a unique adapter with its own mappings. The `parse_payload`/`validate_auth` methods use `# type: ignore[override]` for the instance vs classmethod difference. +- `prev_state` dict keys are `source_path` strings, values are the last numeric value seen. This enables diff-based trigger detection (e.g., CS2 kill counter increments). +- Non-numeric values in payloads are treated as trigger events with value=1.0. + +### What Phase 2 needs +- `GameEventBus` instance should be created as a singleton in `dependencies.py` and injected via FastAPI `Depends()`. +- `AdapterRegistry` is ready for built-in adapters to call `AdapterRegistry.register(MyAdapter)` at import time. +- The `GameIntegrationStore` (Phase 2) will need to store adapter configs that include `adapter_id` fields matching what `parse_payload` expects in `adapter_config`. diff --git a/plans/game-integration/phase-2-storage-api.md b/plans/game-integration/phase-2-storage-api.md new file mode 100644 index 0000000..af1a790 --- /dev/null +++ b/plans/game-integration/phase-2-storage-api.md @@ -0,0 +1,102 @@ +# Phase 2: Storage & API — Game Integration Configs + +**Status:** ✅ Complete +**Parent plan:** [PLAN.md](./PLAN.md) +**Domain:** backend + +## Objective +Create the persistence layer and REST API for managing game integration configurations. Users create/edit/delete game integration configs, and games POST events to an ingestion endpoint. + +## Tasks + +- [x] Task 1: Create `GameIntegrationConfig` dataclass (`storage/game_integration.py`) + - Fields: id (gi_<8hex>), name, adapter_type, enabled, adapter_config (dict), event_mappings (list of EventMapping dicts), created_at, updated_at, description, tags + - `EventMapping` dataclass: event_type, effect, color ([R,G,B]), duration_ms, intensity, priority + - Standard to_dict/from_dict/create_from_kwargs/apply_update methods +- [x] Task 2: Create `GameIntegrationStore` (`storage/game_integration_store.py`) + - Extend BaseSqliteStore[GameIntegrationConfig] + - CRUD methods: create_integration, update_integration, delete (inherited) + - Name uniqueness validation + - get_references() for cascade prevention +- [x] Task 3: Create Pydantic schemas (`api/schemas/game_integration.py`) + - GameIntegrationCreate, GameIntegrationUpdate, GameIntegrationResponse, GameIntegrationListResponse + - EventMappingSchema + - GameEventPayload (for ingestion endpoint) + - AdapterInfoResponse, AdapterListResponse (for adapter metadata endpoint) + - GameIntegrationStatusResponse (for diagnostics) +- [x] Task 4: Create API routes (`api/routes/game_integration.py`) + - GET /api/v1/game-integrations — list all configs + - POST /api/v1/game-integrations — create config + - GET /api/v1/game-integrations/{id} — get config + - PUT /api/v1/game-integrations/{id} — update config + - DELETE /api/v1/game-integrations/{id} — delete config + - POST /api/v1/game-integrations/{id}/event — receive game event payload (ingestion) + - GET /api/v1/game-integrations/{id}/status — connection status, last event, event count + - GET /api/v1/game-integrations/{id}/events — recent events for debugging + - GET /api/v1/game-adapters — list available adapter types + supported events + config schema +- [x] Task 5: Wire into main.py — create store, register in dependencies, include router +- [x] Task 6: Add to STORE_MAP in api/routes/system.py for backup/restore + - N/A — backups are SQLite-level (full database snapshot). Added `game_integrations` to `_ENTITY_TABLES` in `database.py` which automatically includes it in backup/restore. +- [x] Task 7: Add game_integrations_file to StorageConfig in config.py (if JSON-based) or ensure DB table + - Added `"game_integrations"` to `_ENTITY_TABLES` in `database.py` — table auto-created on startup. +- [x] Task 8: Event ingestion logic — parse payload through adapter, publish to EventBus, track per-integration state (for diff detection) +- [x] Task 9: Write tests for store (CRUD, validation, uniqueness) +- [x] Task 10: Write tests for API routes (create, list, update, delete, event ingestion) + +## Files to Modify/Create +- `server/src/wled_controller/storage/game_integration.py` — dataclass models +- `server/src/wled_controller/storage/game_integration_store.py` — SQLite store +- `server/src/wled_controller/api/schemas/game_integration.py` — Pydantic schemas +- `server/src/wled_controller/api/routes/game_integration.py` — REST endpoints +- `server/src/wled_controller/main.py` — wire store + router +- `server/src/wled_controller/api/dependencies.py` — add get_game_integration_store +- `server/src/wled_controller/api/routes/system.py` — add to STORE_MAP +- `server/src/wled_controller/config.py` — add storage config (if needed) +- `server/tests/storage/test_game_integration_store.py` — store tests +- `server/tests/api/test_game_integration_routes.py` — route tests + +## Acceptance Criteria +- Full CRUD for game integration configs via REST API +- Event ingestion endpoint parses payload through the correct adapter and publishes to EventBus +- Adapter metadata endpoint returns available adapters with supported events and config schemas +- Status endpoint shows last event time and event counts +- Store validates name uniqueness +- Included in backup/restore via STORE_MAP +- All tests pass + +## Notes +- The ingestion endpoint must be low-latency — games send at tick rate (16-64 Hz) +- Store per-integration prev_state dict in memory (not persisted) for diff-based trigger detection +- Auth for ingestion endpoint: adapter-level auth (e.g. CS2 token) checked before standard API auth + +## Review Checklist +- [x] All tasks completed +- [x] Code follows project conventions +- [x] No unintended side effects +- [x] Tests pass + +## Handoff to Next Phase + +**Completed:** All 10 tasks implemented and tested. 48 tests pass, 0 failures. Ruff clean. + +### What was built +- `storage/game_integration.py` — `EventMapping` and `GameIntegrationConfig` dataclasses with full `to_dict()`/`from_dict()`/`create_from_kwargs()`/`apply_update()` methods. `apply_update()` returns a new instance (immutable pattern). +- `storage/game_integration_store.py` — `GameIntegrationStore` extending `BaseSqliteStore[GameIntegrationConfig]`. CRUD with name uniqueness, write-through caching. Aliases: `get_all_integrations`, `get_integration`, `delete_integration`. +- `api/schemas/game_integration.py` — Full Pydantic schema suite: `EventMappingSchema`, `GameIntegrationCreate/Update/Response/ListResponse`, `GameEventPayload`, `AdapterInfoResponse/AdapterListResponse`, `GameIntegrationStatusResponse`, `GameEventResponse/RecentEventsResponse`. +- `api/routes/game_integration.py` — 9 endpoints: full CRUD + event ingestion + status + recent events + adapter listing. Ingestion endpoint skips `AuthRequired` (uses adapter-level auth via `validate_auth()`). Per-integration runtime state tracked in-memory (`_prev_states`, `_integration_stats`) with thread-safe access. +- `database.py` — Added `"game_integrations"` to `_ENTITY_TABLES` so the table is auto-created and included in database backups. +- `api/dependencies.py` — Added `get_game_integration_store()` and `get_game_event_bus()` getters + `init_dependencies()` params. +- `api/__init__.py` — Registered `game_integration_router`. +- `main.py` — Creates `GameIntegrationStore(db)` and `GameEventBus()`, passes both to `init_dependencies()`. + +### Key design decisions +- **Ingestion auth**: The `/event` endpoint does NOT use `AuthRequired`. Instead, it calls `adapter_cls.validate_auth()` with request headers and adapter config. This allows game clients (CS2 GSI, etc.) to authenticate with their own token scheme without needing the app's API key. +- **Runtime state**: Per-integration `prev_state` (for diff detection) and event stats are stored in module-level dicts with a threading lock. Not persisted — resets on server restart. +- **Connected heuristic**: An integration is "connected" if it received an event within the last 30 seconds (based on monotonic time). +- **Immutable updates**: `GameIntegrationConfig.apply_update()` returns a new instance; the store replaces the cache entry. + +### What Phase 3+ needs +- `GameEventBus` singleton is now available via `get_game_event_bus()` dependency — Phase 4/5 streams can subscribe to it. +- `GameIntegrationStore` is available via `get_game_integration_store()` — Phase 6 frontend can call the CRUD API. +- Built-in adapters (Phase 3) should call `AdapterRegistry.register(MyAdapter)` at import time to appear in `GET /api/v1/game-adapters`. +- The `adapter_config` field on each integration stores adapter-specific secrets (e.g. CS2 auth token). Phase 3 adapters define their config schema via `get_config_schema()`. diff --git a/plans/game-integration/phase-3-adapters.md b/plans/game-integration/phase-3-adapters.md new file mode 100644 index 0000000..6638fff --- /dev/null +++ b/plans/game-integration/phase-3-adapters.md @@ -0,0 +1,110 @@ +# Phase 3: Built-in Game Adapters + +**Status:** ✅ Complete +**Parent plan:** [PLAN.md](./PLAN.md) +**Domain:** backend + +## Objective +Implement built-in adapters for popular games and ship example community adapter YAML files. Each adapter translates a game's native data format into standardized GameEvents. + +## Tasks + +- [x] Task 1: CS2 Game State Integration adapter (`core/game_integration/adapters/cs2_adapter.py`) + - Parse CS2 GSI JSON payload (player.state, round, map sections) + - Events: player_health, player_armor, player_ammo, player_money, kill, death, round_start, round_end, bomb_planted, bomb_defused, flashbang, team + - Auth: validate payload["auth"]["token"] against adapter_config["auth_token"] + - Diff-based detection for kills (compare match_stats.kills with prev_state) + - Setup instructions: how to create gamestate_integration_*.cfg in CS2 + - Config schema: auth_token (string) +- [x] Task 2: Dota 2 Game State Integration adapter (`core/game_integration/adapters/dota2_adapter.py`) + - Similar to CS2 GSI format but different payload structure + - Events: player_health, player_mana, kill, death, match_start, match_end, gold + - Auth: same pattern as CS2 + - Config schema: auth_token +- [x] Task 3: League of Legends Live Client Data API adapter (`core/game_integration/adapters/lol_adapter.py`) + - Poll-based: fetches from https://127.0.0.1:2999/liveclientdata/allgamedata + - Events: player_health, player_mana, player_level, death, respawn, gold, game_time + - Adapter manages its own polling thread (started/stopped with integration enable/disable) + - Config schema: poll_interval_ms (int, default 500) + - Note: LoL uses self-signed SSL cert — needs verify=False or custom cert handling +- [x] Task 4: Generic webhook adapter (`core/game_integration/adapters/generic_webhook_adapter.py`) + - User-defined JSON path mappings (configured in adapter_config) + - Config schema: mappings list (same format as MappingAdapter YAML) + - Effectively a MappingAdapter configured via API rather than YAML file +- [x] Task 5: Register all built-in adapters in `core/game_integration/adapters/__init__.py` +- [x] Task 6: Create example community adapter YAML files + - `server/src/wled_controller/data/game_adapters/minecraft.yaml` — via webhook mod + - `server/src/wled_controller/data/game_adapters/valorant.yaml` — via Overwolf/Insights API + - `server/src/wled_controller/data/game_adapters/rocket_league.yaml` — via SOS plugin +- [x] Task 7: Community adapter loader — scan data/game_adapters/ on startup, register as available +- [x] Task 8: Write tests for CS2 adapter (payload parsing, auth validation, diff detection) +- [x] Task 9: Write tests for Dota 2 adapter +- [x] Task 10: Write tests for LoL adapter (mock HTTP responses) +- [x] Task 11: Write tests for generic webhook adapter +- [x] Task 12: Write tests for community YAML adapter loading + +## Files to Modify/Create +- `server/src/wled_controller/core/game_integration/adapters/cs2_adapter.py` +- `server/src/wled_controller/core/game_integration/adapters/dota2_adapter.py` +- `server/src/wled_controller/core/game_integration/adapters/lol_adapter.py` +- `server/src/wled_controller/core/game_integration/adapters/generic_webhook_adapter.py` +- `server/src/wled_controller/core/game_integration/adapters/__init__.py` — register all +- `server/src/wled_controller/data/game_adapters/minecraft.yaml` +- `server/src/wled_controller/data/game_adapters/valorant.yaml` +- `server/src/wled_controller/data/game_adapters/rocket_league.yaml` +- `server/tests/core/test_cs2_adapter.py` +- `server/tests/core/test_dota2_adapter.py` +- `server/tests/core/test_lol_adapter.py` +- `server/tests/core/test_generic_webhook_adapter.py` +- `server/tests/core/test_community_adapter_loader.py` + +## Acceptance Criteria +- CS2 adapter correctly parses real GSI payloads into standardized events +- Dota 2 adapter handles its GSI format +- LoL adapter can poll (mocked) and produce events +- Generic webhook adapter translates arbitrary JSON using user-defined mappings +- Community YAML files load and register correctly +- Auth validation works per adapter +- All tests pass with realistic payload samples + +## Notes +- Use real CS2/Dota2 GSI payload samples from documentation for tests +- LoL polling thread must be stoppable (daemon thread or event flag) +- Community adapter directory should be configurable (default: data/game_adapters/) +- Generic webhook adapter reuses MappingAdapter logic from Phase 1 + +## Review Checklist +- [x] All tasks completed +- [x] Code follows project conventions +- [x] No unintended side effects +- [x] Tests pass + +## Handoff to Next Phase + +**Completed:** All 12 tasks implemented and tested. 92 tests pass, 0 failures. Ruff clean. + +### What was built + +- `adapters/cs2_adapter.py` — `CS2Adapter` parsing CS2 GSI payloads. Continuous events: health, armor, money (gold), ammo. Diff-based triggers: kill, death. Phase triggers: round_start, round_end, bomb_planted (objective_captured), bomb_defused (objective_lost). Flash detection (blinded). Team affiliation (team_a/team_b). Auth via `payload["auth"]["token"]`. +- `adapters/dota2_adapter.py` — `Dota2Adapter` parsing Dota 2 GSI payloads. Continuous: health (hp/max_hp ratio), mana (mp/max_mp ratio), gold (configurable max). Diff-based: kill, death. Match flow: match_start (PRE_GAME/GAME_IN_PROGRESS), match_end (POST_GAME/DISCONNECT). Auth via `payload["auth"]["token"]`. +- `adapters/lol_adapter.py` — `LoLAdapter` parsing LoL Live Client Data payloads + `LoLPoller` daemon thread for polling `https://127.0.0.1:2999/liveclientdata/allgamedata`. Continuous: health, mana, level (speed), gold. Triggers: death (health drops to 0), respawn (objective_progress). No auth (local-only API). Poller uses `threading.Event` for clean stop, `ssl.CERT_NONE` for self-signed cert. +- `adapters/generic_webhook_adapter.py` — `GenericWebhookAdapter` delegating to `MappingAdapter` internally. User defines mappings in `adapter_config["mappings"]`. Auth via configurable header (default: Authorization with Bearer prefix support). +- `adapters/__init__.py` — Registers all 4 built-in adapters with `AdapterRegistry` on import. +- `community_loader.py` — Scans `data/game_adapters/` for `.yaml`/`.yml` files, loads them as `MappingAdapter` instances keyed as `community_`. Module-level registry with `register_community_adapters()`, `get_community_adapter()`, `get_community_adapter_info()`. +- `data/game_adapters/minecraft.yaml` — Webhook-based, maps health/armor/food/XP/kills/deaths. +- `data/game_adapters/valorant.yaml` — Webhook-based via Overwolf, maps health/shield/money/kills/deaths/round/spike. +- `data/game_adapters/rocket_league.yaml` — Webhook-based via SOS plugin bridge, maps boost/speed/goals/time/teams. + +### Key design decisions + +- **CS2/Dota2 auth** uses `payload["auth"]["token"]` (not HTTP headers) — matches how Valve's GSI actually sends the token. +- **LoL polling** is opt-in via `LoLPoller` class, not auto-started by the adapter. The integration manager (Phase 4+) should instantiate and manage poller lifecycle. +- **Generic webhook** creates a transient `MappingAdapter` per `parse_payload` call. This is simple and stateless — the adapter_config is the source of truth. For high-frequency usage, caching the MappingAdapter instance could be a future optimization. +- **Community adapters** are separate from `AdapterRegistry` (which holds class-based adapters). They live in `community_loader._community_adapters` since they're instance-based MappingAdapters. + +### What Phase 4+ needs + +- Import `wled_controller.core.game_integration.adapters` in `main.py` to trigger built-in adapter registration. +- Call `register_community_adapters()` from `community_loader` during app startup. +- The adapter listing endpoint (`GET /api/v1/game-adapters`) should also include `get_community_adapter_info()` results. +- LoL polling needs lifecycle management — start `LoLPoller` when a LoL integration is enabled, stop when disabled. diff --git a/plans/game-integration/phase-4-css-stream.md b/plans/game-integration/phase-4-css-stream.md new file mode 100644 index 0000000..aa08907 --- /dev/null +++ b/plans/game-integration/phase-4-css-stream.md @@ -0,0 +1,78 @@ +# Phase 4: GameEventColorStripStream + +**Status:** ✅ Complete +**Parent plan:** [PLAN.md](./PLAN.md) +**Domain:** backend + +## Objective +Create a new ColorStripSource/Stream type that renders LED effects in response to game events. Follows the NotificationColorStripStream pattern — event-driven with a 30 FPS render loop and double-buffered output. + +## Tasks + +- [x] Task 1: Create `GameEventColorStripSource` dataclass in `storage/color_strip_source.py` + - Fields: game_integration_id (str), idle_color (BindableColor), event_mappings (list of EventMapping dicts — override/supplement config-level mappings) + - source_type = "game_event" + - Implement to_dict/from_dict/create_from_kwargs/apply_update + - sharable = False (each target gets its own stream) +- [x] Task 2: Register "game_event" in `_SOURCE_TYPE_MAP` in color_strip_source.py +- [x] Task 3: Create `GameEventColorStripStream` (`core/processing/game_event_stream.py`) + - Constructor: parse event_mappings into lookup dict, subscribe to EventBus + - `_on_game_event(event)` — callback from EventBus, enqueue effect (thread-safe via deque) + - Effect types: flash, pulse, sweep, color_shift, breathing + - Priority-based layering — higher priority effects override lower (same as notification) + - 30 FPS background render thread with frame_time sleep + - Double-buffered output under threading.Lock + - Idle: output idle_color when no active effects + - `get_latest_colors()` → returns current rendered frame (np.ndarray) + - `start()` / `stop()` for lifecycle + - Cleanup: unsubscribe from EventBus on stop +- [x] Task 4: Register in `_SIMPLE_STREAM_MAP` in `color_strip_stream_manager.py` +- [x] Task 5: Wire EventBus injection — stream needs access to the singleton EventBus + - Added `game_event_bus` parameter to ColorStripStreamManager constructor + - Injection via `set_event_bus()` using same hasattr pattern as asset_store +- [x] Task 6: Write tests for GameEventColorStripSource (serialization, factory, update) +- [x] Task 7: Write tests for GameEventColorStripStream (event → effect rendering, priority, idle state, lifecycle) + +## Files to Modify/Create +- `server/src/wled_controller/storage/color_strip_source.py` — add GameEventColorStripSource + register +- `server/src/wled_controller/core/processing/game_event_stream.py` — new stream class +- `server/src/wled_controller/core/processing/color_strip_stream_manager.py` — register in _SIMPLE_STREAM_MAP, inject EventBus +- `server/src/wled_controller/core/processing/processor_manager.py` — pass EventBus to stream manager (if needed) +- `server/tests/core/test_game_event_css.py` — source + stream tests + +## Acceptance Criteria +- "game_event" source type serializes/deserializes correctly +- Stream subscribes to EventBus and renders effects when events arrive +- Multiple simultaneous effects layer by priority +- Idle state outputs the configured idle_color +- Stream cleans up subscriptions on stop +- All tests pass + +## Notes +- Reuse effect rendering logic from NotificationColorStripStream where possible (flash, pulse, sweep are identical) +- Consider extracting shared effect rendering into a utility if duplication is significant +- The stream needs the EventBus singleton — simplest injection is via the stream manager's dependencies +- ⚠️ Big Bang: color_strip_source.py and stream_manager.py are shared files — coordinate with Phase 5 if running in parallel + +## Review Checklist +- [x] All tasks completed +- [x] Code follows project conventions +- [x] No unintended side effects +- [x] Tests pass (28/28) + +## Handoff to Next Phase + +### What was built +- `GameEventColorStripSource` dataclass in `storage/color_strip_source.py` with full serialization, factory, and update support +- `GameEventColorStripStream` in `core/processing/game_event_stream.py` — event-driven stream with 5 effects (flash, pulse, sweep, color_shift, breathing), 30 FPS render loop, double-buffered output, priority-based effect layering +- EventBus injection via `game_event_bus` parameter on `ColorStripStreamManager` constructor + `set_event_bus()` method on the stream + +### Integration points for later phases +- **Phase 7 (frontend)**: The `source_type = "game_event"` needs a UI editor. Fields: `game_integration_id` (EntitySelect), `idle_color` (BindableColor picker), `event_mappings` (list of EventMapping dicts with effect/color/duration/intensity/priority), `led_count` +- **Phase 8 (wiring)**: `processor_manager.py` needs to pass the `GameEventBus` singleton to `ColorStripStreamManager` via the new `game_event_bus=` constructor parameter. The bus is created in Phase 1's `init_dependencies()`. + +### Files modified +- `server/src/wled_controller/storage/color_strip_source.py` — added `GameEventColorStripSource` class + registered in `_SOURCE_TYPE_MAP` +- `server/src/wled_controller/core/processing/game_event_stream.py` — new file +- `server/src/wled_controller/core/processing/color_strip_stream_manager.py` — added import, `_SIMPLE_STREAM_MAP` entry, `game_event_bus` constructor param, injection hook +- `server/tests/core/test_game_event_css.py` — 28 tests covering source serialization, stream lifecycle, rendering, effects, auto-size, and hot-update diff --git a/plans/game-integration/phase-5-value-source.md b/plans/game-integration/phase-5-value-source.md new file mode 100644 index 0000000..e6a2614 --- /dev/null +++ b/plans/game-integration/phase-5-value-source.md @@ -0,0 +1,69 @@ +# Phase 5: GameEventValueSource + +**Status:** ✅ Complete +**Parent plan:** [PLAN.md](./PLAN.md) +**Domain:** backend + +## Objective +Create a new ValueSource type that exposes game metrics (health, ammo, mana, etc.) as 0.0-1.0 scalar values. These can be bound to any existing effect parameter (brightness, speed, color position) via the BindableFloat/BindableColor system. + +## Tasks + +- [x] Task 1: Create `GameEventValueSource` dataclass in `storage/value_source.py` + - Fields: game_integration_id (str), event_type (str — from standard vocabulary), min_game_value (float, default 0.0), max_game_value (float, default 100.0), smoothing (float, 0.0-1.0, EMA alpha), default_value (float, 0.5), timeout (float, seconds before reverting to default) + - source_type = "game_event" + - Implement to_dict/from_dict +- [x] Task 2: Register "game_event" in `_VALUE_SOURCE_MAP` in value_source.py +- [x] Task 3: Create `GameEventValueStream` runtime resolver (`core/value_sources/game_event_value_source.py`) + - Subscribe to EventBus for the configured event_type + - Normalize incoming value using min/max mapping → 0.0-1.0 + - Apply EMA smoothing if configured + - Track last_event_time for timeout detection + - `get_value()` → returns current normalized value (or default if timed out) + - `get_color()` → returns None (game event value source only provides scalars) + - Thread-safe (EventBus callback + get_value from render thread) + - Cleanup: unsubscribe from EventBus on release +- [x] Task 4: Register in value source manager / factory + - Add case for "game_event" source_type in the value stream creation logic + - Inject EventBus reference +- [x] Task 5: Write tests for GameEventValueSource (serialization, from_dict, defaults) +- [x] Task 6: Write tests for GameEventValueStream (normalization, smoothing, timeout, thread safety) + +## Files to Modify/Create +- `server/src/wled_controller/storage/value_source.py` — add GameEventValueSource + register +- `server/src/wled_controller/core/value_sources/game_event_value_source.py` — runtime resolver +- `server/src/wled_controller/core/processing/value_stream.py` — register factory case + event_bus param +- `server/tests/core/test_game_event_value_source.py` — tests + +## Acceptance Criteria +- "game_event" value source type serializes/deserializes correctly +- Runtime resolver normalizes game values to 0.0-1.0 using min/max +- EMA smoothing works correctly (smooth transitions, not jumpy) +- Timeout reverts to default_value when no events received +- Can be bound to BindableFloat properties on any ColorStripSource +- All tests pass + +## Notes +- EMA formula: `smoothed = alpha * new_value + (1 - alpha) * smoothed` where alpha = 1 - smoothing +- Timeout uses monotonic clock — compare current time vs last_event_time +- Best suited for continuous event types (health, mana, ammo) — triggers (kill, death) are less useful here +- ⚠️ Big Bang: value_source.py is a shared file — coordinate with Phase 4 if running in parallel + +## Review Checklist +- [x] All tasks completed +- [x] Code follows project conventions +- [x] No unintended side effects +- [x] Tests pass (24/24) + +## Handoff to Next Phase + +### What was implemented +- **GameEventValueSource** dataclass in `storage/value_source.py` with all required fields (game_integration_id, event_type, min/max mapping, smoothing, default_value, timeout) +- **GameEventValueStream** in new module `core/value_sources/game_event_value_source.py` — subscribes to GameEventBus, normalizes values, applies EMA smoothing, handles timeout with monotonic clock, thread-safe via threading.Lock +- **ValueStreamManager** updated with `event_bus` parameter and factory case for `GameEventValueSource` in `core/processing/value_stream.py` +- **24 tests** covering serialization, normalization (7 cases), smoothing (3 cases), timeout (4 cases), lifecycle (4 cases), thread safety, and hot-update + +### Integration notes for downstream phases +- `ValueStreamManager.__init__` now accepts an optional `event_bus: GameEventBus` parameter — Phase 8 (wiring) needs to pass the EventBus instance when constructing ValueStreamManager in `dependencies.py` +- New module `core/value_sources/` created — contains `__init__.py` and `game_event_value_source.py` +- No changes to API schemas or routes — Phase 7 (frontend) will need to add "game_event" to the value source editor UI diff --git a/plans/game-integration/phase-6-frontend-management.md b/plans/game-integration/phase-6-frontend-management.md new file mode 100644 index 0000000..6f624c6 --- /dev/null +++ b/plans/game-integration/phase-6-frontend-management.md @@ -0,0 +1,123 @@ +# Phase 6: Frontend — Game Integration Management UI + +**Status:** ✅ Complete +**Parent plan:** [PLAN.md](./PLAN.md) +**Domain:** frontend + +## Objective +Build the UI for creating, configuring, and monitoring game integrations. Users pick a game from a visual grid, configure adapter settings with guided instructions, set up event-to-effect mappings visually, and monitor live events. + +## Tasks + +- [x] Task 1: Add "Game" tab/group to Streams tree navigation in streams.ts + - New tree group with game controller icon + - Children: game integrations list, game adapters info +- [x] Task 2: Create game integration cards in the Streams tab + - CardSection instance for game integrations + - Card shows: game name/icon, adapter type badge, status indicator (connected/waiting/error), last event timestamp, event count + - Status indicator: green dot = events received recently, yellow = waiting, red = error/timeout +- [x] Task 3: Create game integration editor modal (`templates/modals/game-integration-editor.html`) + - Step 1: Game picker — searchable grid of available adapters with game icons (IconSelect pattern) + - Step 2: Adapter config — auto-generated fields from adapter's config_schema (text inputs for auth tokens, number inputs for intervals) + - Step 3: Setup instructions — per-game markdown instructions (e.g. CS2 cfg file content) + - Step 4: Event mapping editor — visual grid of standard event categories + - Each mapping row: event type (dropdown), effect type (IconSelect: flash/pulse/sweep/color_shift/breathing), color picker, duration slider, intensity slider, priority + - Effect preset selector at top of mapping editor (dropdown: "FPS Combat", "MOBA Health", etc.) + - Name, description, tags fields +- [x] Task 4: Create TypeScript module `static/js/features/game-integration.ts` + - CRUD functions using fetchWithAuth + - Cache for game integrations data + - Cache for game adapters metadata + - Card rendering functions + - Modal open/save/delete handlers + - Event mapping editor logic (add/remove/reorder mappings) +- [x] Task 5: Game adapter icons — add SVG icons for supported games in `core/icons.ts` + - Generic gamepad icon for unknown games + - Stylized icons for CS2, LoL, Dota 2, Minecraft, Valorant, Rocket League +- [x] Task 6: Live event monitor panel + - Expandable panel on the game integration card (or modal tab) + - Shows real-time feed of incoming events: timestamp, event_type, value, color-coded by category + - Fetches from GET /api/v1/game-integrations/{id}/events (polling every 2s or WebSocket later) + - Useful for debugging: "is my game sending data?" +- [x] Task 7: Connection test button + - Button in modal that opens a test panel showing "Waiting for events..." + - When first event arrives, shows success with event details + - Helps users verify their game config is correct +- [x] Task 8: Add i18n keys for all new strings (en.json, ru.json, zh.json) + - Game integration section titles, modal labels, status messages, event category names, effect type names, error messages +- [x] Task 9: CSS styles for game integration cards and modal (`static/css/game-integration.css`) + - Game picker grid layout + - Event mapping editor rows + - Status indicators (colored dots) + - Live event monitor feed +- [x] Task 10: Wire into app.ts — import module, add to window exports for onclick handlers +- [x] Task 11: Wire into streams.ts — add cache, load function, CardSection, tree nav integration + +## Files to Modify/Create +- `server/src/wled_controller/static/js/features/game-integration.ts` — main module +- `server/src/wled_controller/static/js/features/streams.ts` — add game tab, cache, card section +- `server/src/wled_controller/static/js/app.ts` — import and window exports +- `server/src/wled_controller/static/js/core/icons.ts` — game icons +- `server/src/wled_controller/static/js/types.ts` — TypeScript types for game integration +- `server/src/wled_controller/static/css/game-integration.css` — styles +- `server/src/wled_controller/templates/modals/game-integration-editor.html` — modal template +- `server/src/wled_controller/templates/index.html` — include modal template +- `server/src/wled_controller/static/locales/en.json` — i18n keys +- `server/src/wled_controller/static/locales/ru.json` — i18n keys +- `server/src/wled_controller/static/locales/zh.json` — i18n keys + +## Acceptance Criteria +- Game integration tab appears in Streams tree navigation +- Cards display with correct status indicators and game icons +- Modal wizard guides user through game selection → config → mapping +- Adapter-specific config fields are auto-generated from schema +- Setup instructions display per game +- Event mapping editor allows visual configuration with effect previews +- Effect presets populate mapping editor with sensible defaults +- Live event monitor shows incoming events +- Connection test provides clear feedback +- All i18n keys present in all 3 languages +- UI follows existing project conventions (no emoji, SVG icons, IconSelect/EntitySelect) + +## Notes +- Follow existing modal patterns: Modal base class, snapshotValues() for dirty check +- Follow CardSection pattern for entity list with reconciliation +- Use fetchWithAuth for ALL API calls +- Icons must be SVG paths in icons.ts — NEVER emoji +- Use IconSelect for game picker and effect type selector +- Use EntitySelect for game_integration_id references +- The event mapping editor is the most complex UI piece — consider a sub-component approach + +## Review Checklist +- [x] All tasks completed +- [x] Code follows frontend conventions +- [x] No unintended side effects +- [x] TypeScript compiles without errors (only pre-existing SystemMetricsValueSource error remains) +- [x] Bundle builds successfully + +## Handoff to Next Phase + +All 11 tasks implemented. Key implementation details: + +**Files created:** +- `server/src/wled_controller/static/js/features/game-integration.ts` — main module (CRUD, cards, modal handlers, event monitor, connection test) +- `server/src/wled_controller/static/css/game-integration.css` — styles for mapping editor, event feed, status indicators, connection test panel +- `server/src/wled_controller/templates/modals/game-integration-editor.html` — modal with adapter picker, config fields, mapping editor, live events, connection test + +**Files modified:** +- `icon-paths.ts` — added gamepad2, crosshair, swords, shield, pickaxe, rocketIcon, circleDot +- `icons.ts` — added ICON_GAMEPAD, ICON_CROSSHAIR, ICON_SWORDS, ICON_SHIELD, ICON_PICKAXE, ICON_ROCKET_ICON, ICON_CIRCLE_DOT, getGameAdapterIcon() +- `types.ts` — added GameIntegration, GameAdapterInfo, GameEventMapping, GameEventRecord, GameIntegrationStatus +- `state.ts` — added gameIntegrationsCache, gameAdaptersCache, _cachedGameIntegrations, _cachedGameAdapters +- `streams.ts` — added game tab to tree nav (under Integrations group), CardSection, cache fetching, reconciliation +- `app.ts` — imported and wired all game integration functions to window +- `global.d.ts` — added window type declarations for game integration functions +- `index.html` — included game-integration-editor.html modal +- `all.css` — imported game-integration.css +- `en.json`, `ru.json`, `zh.json` — added ~50 i18n keys each + +**API endpoints consumed:** GET/POST/PUT/DELETE /game-integrations, GET /game-adapters, GET /game-integrations/{id}/events, GET /game-integrations/{id}/status + +**Conventions followed:** No emoji (SVG icons only), fetchWithAuth for all API calls, IconSelect for adapter picker, Modal subclass with snapshotValues() dirty check, TagInput for tags, CardSection with reconciliation, cache.invalidate() before reload, all strings via t() with i18n keys in 3 locales. + +**Note:** The mapping editor uses plain ` - ${CONDITION_TYPE_KEYS.map(k => ``).join('')} +
+ - +
-
+
`; - const typeSelect = row.querySelector('.condition-type-select') as HTMLSelectElement; - const container = row.querySelector('.condition-fields-container') as HTMLElement; + const typeSelect = row.querySelector('.rule-type-select') as HTMLSelectElement; + const container = row.querySelector('.rule-fields-container') as HTMLElement; - // Attach IconSelect to the condition type dropdown - const condIconSelect = new IconSelect({ + // Attach IconSelect to the rule type dropdown + const ruleIconSelect = new IconSelect({ target: typeSelect, - items: _buildConditionTypeItems(), + items: _buildRuleTypeItems(), columns: 4, } as any); function renderFields(type: any, data: any) { - if (type === 'always') { - container.innerHTML = `${t('automations.condition.always.hint')}`; - return; - } if (type === 'startup') { - container.innerHTML = `${t('automations.condition.startup.hint')}`; + container.innerHTML = `${t('automations.rule.startup.hint')}`; return; } if (type === 'time_of_day') { @@ -670,12 +668,12 @@ function addAutomationConditionRow(condition: any) { const [eh, em] = endTime.split(':').map(Number); const pad = (n: number) => String(n).padStart(2, '0'); container.innerHTML = ` -
- - +
+ +
- ${t('automations.condition.time_of_day.start_time')} + ${t('automations.rule.time_of_day.start_time')}
: @@ -684,7 +682,7 @@ function addAutomationConditionRow(condition: any) {
- ${t('automations.condition.time_of_day.end_time')} + ${t('automations.rule.time_of_day.end_time')}
: @@ -692,7 +690,7 @@ function addAutomationConditionRow(condition: any) {
- ${t('automations.condition.time_of_day.overnight_hint')} + ${t('automations.rule.time_of_day.overnight_hint')}
`; _wireTimeRangePicker(container); return; @@ -701,16 +699,16 @@ function addAutomationConditionRow(condition: any) { const idleMinutes = data.idle_minutes ?? 5; const whenIdle = data.when_idle ?? true; container.innerHTML = ` -
-
- - +
+
+ +
-
- - + +
`; @@ -719,12 +717,12 @@ function addAutomationConditionRow(condition: any) { if (type === 'display_state') { const dState = data.state || 'on'; container.innerHTML = ` -
-
- - + +
`; @@ -735,21 +733,21 @@ function addAutomationConditionRow(condition: any) { const payload = data.payload || ''; const matchMode = data.match_mode || 'exact'; container.innerHTML = ` -
-
- - +
+
+ +
-
- - +
+ +
-
- - + + +
`; @@ -764,37 +762,37 @@ function addAutomationConditionRow(condition: any) { `` ).join(''); container.innerHTML = ` -
- ${t('automations.condition.home_assistant.hint')} -
- - ${haOptions}
-
- - ${entityId ? `` : ''}
-
- - +
+ +
-
- - + + +
`; // Wire HA source EntitySelect - const haSrcSelect = container.querySelector('.condition-ha-source-id') as HTMLSelectElement; + const haSrcSelect = container.querySelector('.rule-ha-source-id') as HTMLSelectElement; new EntitySelect({ target: haSrcSelect, getItems: () => _cachedHASources.map((s: any) => ({ @@ -802,34 +800,36 @@ function addAutomationConditionRow(condition: any) { desc: s.connected ? t('ha_source.connected') : t('ha_source.disconnected'), })), placeholder: t('palette.search'), - onChange: (newId: string) => _loadHAEntitiesForCondition(newId, container), + onChange: (newId: string) => _loadHAEntitiesForRule(newId, container), }); // Wire entity EntitySelect - const entitySelect = container.querySelector('.condition-ha-entity-id') as HTMLSelectElement; + const entitySelect = container.querySelector('.rule-ha-entity-id') as HTMLSelectElement; const entityES = new EntitySelect({ target: entitySelect, - getItems: () => _haConditionEntities.map((e: any) => ({ + getItems: () => _haRuleEntities.map((e: any) => ({ value: e.entity_id, label: e.friendly_name || e.entity_id, icon: getHAEntityIcon(e), desc: e.state || '', })), placeholder: t('ha_light.mapping.search_entity'), }); + // Store ref so _loadHAEntitiesForRule can refresh the trigger display + (entitySelect as any)._entitySelect = entityES; // Wire match mode IconSelect - const matchSelect = container.querySelector('.condition-ha-match-mode') as HTMLSelectElement; + const matchSelect = container.querySelector('.rule-ha-match-mode') as HTMLSelectElement; new IconSelect({ target: matchSelect, items: [ - { value: 'exact', icon: _icon(P.check), label: t('automations.condition.mqtt.match_mode.exact'), desc: t('automations.condition.ha.match_mode.exact.desc') }, - { value: 'contains', icon: _icon(P.search), label: t('automations.condition.mqtt.match_mode.contains'), desc: t('automations.condition.ha.match_mode.contains.desc') }, - { value: 'regex', icon: _icon(P.code), label: t('automations.condition.mqtt.match_mode.regex'), desc: t('automations.condition.ha.match_mode.regex.desc') }, + { value: 'exact', icon: _icon(P.check), label: t('automations.rule.mqtt.match_mode.exact'), desc: t('automations.rule.ha.match_mode.exact.desc') }, + { value: 'contains', icon: _icon(P.search), label: t('automations.rule.mqtt.match_mode.contains'), desc: t('automations.rule.ha.match_mode.contains.desc') }, + { value: 'regex', icon: _icon(P.code), label: t('automations.rule.mqtt.match_mode.regex'), desc: t('automations.rule.ha.match_mode.regex.desc') }, ], columns: 1, }); // Load entities if source is already selected - if (haSourceId) _loadHAEntitiesForCondition(haSourceId, container); + if (haSourceId) _loadHAEntitiesForRule(haSourceId, container); return; } @@ -837,22 +837,22 @@ function addAutomationConditionRow(condition: any) { if (data.token) { const webhookUrl = getBaseOrigin() + '/api/v1/webhooks/' + data.token; container.innerHTML = ` -
- ${t('automations.condition.webhook.hint')} -
- +
+ ${t('automations.rule.webhook.hint')} +
+
- - + +
- +
`; } else { container.innerHTML = ` -
- ${t('automations.condition.webhook.hint')} -

${t('automations.condition.webhook.save_first')}

+
+ ${t('automations.rule.webhook.hint')} +

${t('automations.rule.webhook.save_first')}

`; } return; @@ -860,30 +860,30 @@ function addAutomationConditionRow(condition: any) { const appsValue = (data.apps || []).join('\n'); const matchType = data.match_type || 'running'; container.innerHTML = ` -
-
- - + + + +
-
-
- - +
+
+ +
- +
`; - const textarea = container.querySelector('.condition-apps') as HTMLTextAreaElement; + const textarea = container.querySelector('.rule-apps') as HTMLTextAreaElement; attachProcessPicker(container, textarea); // Attach IconSelect to match type - const matchSel = container.querySelector('.condition-match-type'); + const matchSel = container.querySelector('.rule-match-type'); if (matchSel) { new IconSelect({ target: matchSel, @@ -893,7 +893,7 @@ function addAutomationConditionRow(condition: any) { } } - renderFields(condType, condition); + renderFields(ruleType, rule); typeSelect.addEventListener('change', () => { renderFields(typeSelect.value, {}); }); @@ -903,61 +903,59 @@ function addAutomationConditionRow(condition: any) { -function getAutomationEditorConditions() { - const rows = document.querySelectorAll('#automation-conditions-list .automation-condition-row'); - const conditions: any[] = []; +function getAutomationEditorRules() { + const rows = document.querySelectorAll('#automation-rules-list .automation-rule-row'); + const rules: any[] = []; rows.forEach(row => { - const typeSelect = row.querySelector('.condition-type-select') as HTMLSelectElement; - const condType = typeSelect ? typeSelect.value : 'application'; - if (condType === 'always') { - conditions.push({ condition_type: 'always' }); - } else if (condType === 'startup') { - conditions.push({ condition_type: 'startup' }); - } else if (condType === 'time_of_day') { - conditions.push({ - condition_type: 'time_of_day', - start_time: (row.querySelector('.condition-start-time') as HTMLInputElement).value || '00:00', - end_time: (row.querySelector('.condition-end-time') as HTMLInputElement).value || '23:59', + const typeSelect = row.querySelector('.rule-type-select') as HTMLSelectElement; + const ruleType = typeSelect ? typeSelect.value : 'application'; + if (ruleType === 'startup') { + rules.push({ rule_type: 'startup' }); + } else if (ruleType === 'time_of_day') { + rules.push({ + rule_type: 'time_of_day', + start_time: (row.querySelector('.rule-start-time') as HTMLInputElement).value || '00:00', + end_time: (row.querySelector('.rule-end-time') as HTMLInputElement).value || '23:59', }); - } else if (condType === 'system_idle') { - conditions.push({ - condition_type: 'system_idle', - idle_minutes: parseInt((row.querySelector('.condition-idle-minutes') as HTMLInputElement).value, 10) || 5, - when_idle: (row.querySelector('.condition-when-idle') as HTMLSelectElement).value === 'true', + } else if (ruleType === 'system_idle') { + rules.push({ + rule_type: 'system_idle', + idle_minutes: parseInt((row.querySelector('.rule-idle-minutes') as HTMLInputElement).value, 10) || 5, + when_idle: (row.querySelector('.rule-when-idle') as HTMLSelectElement).value === 'true', }); - } else if (condType === 'display_state') { - conditions.push({ - condition_type: 'display_state', - state: (row.querySelector('.condition-display-state') as HTMLSelectElement).value || 'on', + } else if (ruleType === 'display_state') { + rules.push({ + rule_type: 'display_state', + state: (row.querySelector('.rule-display-state') as HTMLSelectElement).value || 'on', }); - } else if (condType === 'mqtt') { - conditions.push({ - condition_type: 'mqtt', - topic: (row.querySelector('.condition-mqtt-topic') as HTMLInputElement).value.trim(), - payload: (row.querySelector('.condition-mqtt-payload') as HTMLInputElement).value, - match_mode: (row.querySelector('.condition-mqtt-match-mode') as HTMLSelectElement).value || 'exact', + } else if (ruleType === 'mqtt') { + rules.push({ + rule_type: 'mqtt', + topic: (row.querySelector('.rule-mqtt-topic') as HTMLInputElement).value.trim(), + payload: (row.querySelector('.rule-mqtt-payload') as HTMLInputElement).value, + match_mode: (row.querySelector('.rule-mqtt-match-mode') as HTMLSelectElement).value || 'exact', }); - } else if (condType === 'webhook') { - const tokenInput = row.querySelector('.condition-webhook-token') as HTMLInputElement; - const cond: any = { condition_type: 'webhook' }; - if (tokenInput && tokenInput.value) cond.token = tokenInput.value; - conditions.push(cond); - } else if (condType === 'home_assistant') { - conditions.push({ - condition_type: 'home_assistant', - ha_source_id: (row.querySelector('.condition-ha-source-id') as HTMLSelectElement).value, - entity_id: (row.querySelector('.condition-ha-entity-id') as HTMLSelectElement).value.trim(), - state: (row.querySelector('.condition-ha-state') as HTMLInputElement).value, - match_mode: (row.querySelector('.condition-ha-match-mode') as HTMLSelectElement).value || 'exact', + } else if (ruleType === 'webhook') { + const tokenInput = row.querySelector('.rule-webhook-token') as HTMLInputElement; + const r: any = { rule_type: 'webhook' }; + if (tokenInput && tokenInput.value) r.token = tokenInput.value; + rules.push(r); + } else if (ruleType === 'home_assistant') { + rules.push({ + rule_type: 'home_assistant', + ha_source_id: (row.querySelector('.rule-ha-source-id') as HTMLSelectElement).value, + entity_id: (row.querySelector('.rule-ha-entity-id') as HTMLSelectElement).value.trim(), + state: (row.querySelector('.rule-ha-state') as HTMLInputElement).value, + match_mode: (row.querySelector('.rule-ha-match-mode') as HTMLSelectElement).value || 'exact', }); } else { - const matchType = (row.querySelector('.condition-match-type') as HTMLSelectElement).value; - const appsText = (row.querySelector('.condition-apps') as HTMLTextAreaElement).value.trim(); + const matchType = (row.querySelector('.rule-match-type') as HTMLSelectElement).value; + const appsText = (row.querySelector('.rule-apps') as HTMLTextAreaElement).value.trim(); const apps = appsText ? appsText.split('\n').map(a => a.trim()).filter(Boolean) : []; - conditions.push({ condition_type: 'application', apps, match_type: matchType }); + rules.push({ rule_type: 'application', apps, match_type: matchType }); } }); - return conditions; + return rules; } export async function saveAutomationEditor() { @@ -975,8 +973,8 @@ export async function saveAutomationEditor() { const body = { name, enabled: enabledInput.checked, - condition_logic: logicSelect.value, - conditions: getAutomationEditorConditions(), + rule_logic: logicSelect.value, + rules: getAutomationEditorRules(), scene_preset_id: (document.getElementById('automation-scene-id') as HTMLSelectElement).value || null, deactivation_mode: (document.getElementById('automation-deactivation-mode') as HTMLSelectElement).value, deactivation_scene_preset_id: (document.getElementById('automation-fallback-scene-id') as HTMLSelectElement).value || null, @@ -1026,11 +1024,11 @@ export async function toggleAutomationEnabled(automationId: any, enable: any) { } export function copyWebhookUrl(btn: any) { - const input = btn.closest('.webhook-url-row').querySelector('.condition-webhook-url') as HTMLInputElement; + const input = btn.closest('.webhook-url-row').querySelector('.rule-webhook-url') as HTMLInputElement; if (!input || !input.value) return; const onCopied = () => { const orig = btn.textContent; - btn.textContent = t('automations.condition.webhook.copied'); + btn.textContent = t('automations.rule.webhook.copied'); setTimeout(() => { btn.textContent = orig; }, 1500); }; if (navigator.clipboard && window.isSecureContext) { 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 b9f0d4f..df4dc2c 100644 --- a/server/src/wled_controller/static/js/features/color-strips.ts +++ b/server/src/wled_controller/static/js/features/color-strips.ts @@ -3,7 +3,7 @@ */ import { fetchWithAuth, escapeHtml } from '../core/api.ts'; -import { _cachedSyncClocks, _cachedCSPTemplates, _cachedWeatherSources, _cachedValueSources, audioSourcesCache, streamsCache, colorStripSourcesCache, valueSourcesCache, csptCache, gradientsCache, weatherSourcesCache, GradientEntity } from '../core/state.ts'; +import { _cachedSyncClocks, _cachedCSPTemplates, _cachedWeatherSources, _cachedValueSources, audioSourcesCache, streamsCache, colorStripSourcesCache, valueSourcesCache, csptCache, gradientsCache, weatherSourcesCache, GradientEntity, _cachedGameIntegrations, _cachedGameAdapters, gameIntegrationsCache } from '../core/state.ts'; import { t } from '../core/i18n.ts'; import { showToast, showConfirm, desktopFocus } from '../core/ui.ts'; import { Modal } from '../core/modal.ts'; @@ -14,13 +14,14 @@ import { ICON_AUDIO_LOOPBACK, ICON_TIMER, ICON_LINK_SOURCE, ICON_FILM, ICON_LINK, ICON_SPARKLES, ICON_ACTIVITY, ICON_CLOCK, ICON_BELL, ICON_TEST, ICON_AUTOMATION, ICON_FAST_FORWARD, ICON_THERMOMETER, ICON_TRASH, ICON_PATTERN_TEMPLATE, + ICON_GAMEPAD, } from '../core/icons.ts'; import * as P from '../core/icon-paths.ts'; import { wrapCard } from '../core/card-colors.ts'; import type { ColorStripSource } from '../types.ts'; import { bindableValue, bindableColor } from '../types.ts'; import { TagInput, renderTagChips } from '../core/tag-input.ts'; -import { IconSelect, showTypePicker } from '../core/icon-select.ts'; +import { IconSelect, showTypePicker, type IconSelectItem } from '../core/icon-select.ts'; import { EntitySelect } from '../core/entity-palette.ts'; import { BindableScalarWidget } from '../core/bindable-scalar.ts'; import { BindableColorWidget } from '../core/bindable-color.ts'; @@ -86,6 +87,10 @@ class CSSEditorModal extends Modal { if (_kcInterpolationIconSelect) { _kcInterpolationIconSelect.destroy(); _kcInterpolationIconSelect = null; } if (_kcSmoothingWidget) { _kcSmoothingWidget.destroy(); _kcSmoothingWidget = null; } if (_kcBrightnessWidget) { _kcBrightnessWidget.destroy(); _kcBrightnessWidget = null; } + if (_gameEventIdleColorWidget) { _gameEventIdleColorWidget.destroy(); _gameEventIdleColorWidget = null; } + if (_cssGameIntegrationEntitySelect) { _cssGameIntegrationEntitySelect.destroy(); _cssGameIntegrationEntitySelect = null; } + _destroyCSSGameMappingIconSelects(); + if (_cssGamePresetIconSelect) { _cssGamePresetIconSelect.destroy(); _cssGamePresetIconSelect = null; } compositeDestroyEntitySelects(); } @@ -141,6 +146,9 @@ class CSSEditorModal extends Modal { kc_smoothing: _kcSmoothingWidget ? JSON.stringify(_kcSmoothingWidget.getValue()) : '0.3', kc_brightness: _kcBrightnessWidget ? JSON.stringify(_kcBrightnessWidget.getValue()) : '1.0', kc_rects: JSON.stringify(_kcEditorRects), + ge_integration: (document.getElementById('css-editor-game-integration') as HTMLInputElement)?.value || '', + ge_idle_color: _gameEventIdleColorWidget ? JSON.stringify(_gameEventIdleColorWidget.getValue()) : '[]', + ge_mappings: JSON.stringify(_cssGameMappings), tags: JSON.stringify(_cssTagsInput ? _cssTagsInput.getValue() : []), }; } @@ -168,6 +176,7 @@ let _audioColorWidget: BindableColorWidget | null = null; let _audioColorPeakWidget: BindableColorWidget | null = null; let _apiInputFallbackColorWidget: BindableColorWidget | null = null; let _candlelightColorWidget: BindableColorWidget | null = null; +let _gameEventIdleColorWidget: BindableColorWidget | null = null; // ── EntitySelect instances for CSS editor ── let _cssPictureSourceEntitySelect: any = null; @@ -251,6 +260,7 @@ const CSS_TYPE_KEYS = [ 'picture', 'picture_advanced', 'static', 'gradient', 'color_cycle', 'effect', 'composite', 'mapped', 'audio', 'api_input', 'notification', 'daylight', 'candlelight', 'weather', 'processed', 'key_colors', + 'game_event', ]; function _buildCSSTypeItems() { @@ -298,6 +308,7 @@ const CSS_SECTION_MAP: Record = { 'weather': 'css-editor-weather-section', 'processed': 'css-editor-processed-section', 'key_colors': 'css-editor-key-colors-section', + 'game_event': 'css-editor-game-event-section', }; const CSS_ALL_SECTION_IDS = [...new Set(Object.values(CSS_SECTION_MAP))]; @@ -309,6 +320,7 @@ const CSS_TYPE_SETUP: Record void> = { gradient: () => { _ensureGradientPresetEntitySelect(); _ensureGradientEasingIconSelect(); requestAnimationFrame(() => gradientRenderAll()); }, notification: () => { ensureNotificationEffectIconSelect(); ensureNotificationFilterModeIconSelect(); }, candlelight: () => _ensureCandleTypeIconSelect(), + game_event: () => { _populateGameIntegrationDropdownCSS(); _initCSSGamePresetIconSelect(); }, weather: () => { weatherSourcesCache.fetch().then(() => _populateWeatherSourceDropdown()); }, composite: () => compositeRenderList(), mapped: () => _mappedRenderList(), @@ -569,6 +581,269 @@ function _ensureKcBrightnessWidget(): BindableScalarWidget { return _kcBrightnessWidget; } +// ── Game Event CSS helpers ── + +function _ensureGameEventIdleColorWidget(): BindableColorWidget { + if (!_gameEventIdleColorWidget) { + _gameEventIdleColorWidget = new BindableColorWidget({ + container: document.getElementById('css-editor-game-event-idle-color-container')!, + default: [0, 0, 0], + valueSources: () => _cachedValueSources, + idPrefix: 'css-editor-ge-idle-color', + }); + } + return _gameEventIdleColorWidget; +} + +let _cssGameIntegrationEntitySelect: EntitySelect | null = null; + +function _populateGameIntegrationDropdownCSS(selectedId: string = '') { + const sel = document.getElementById('css-editor-game-integration') as HTMLSelectElement; + const integrations = _cachedGameIntegrations || []; + const prev = selectedId || sel.value; + sel.innerHTML = `` + + integrations.map(gi => ``).join(''); + sel.value = prev || ''; + + if (_cssGameIntegrationEntitySelect) _cssGameIntegrationEntitySelect.destroy(); + _cssGameIntegrationEntitySelect = new EntitySelect({ + target: sel, + getItems: () => integrations.map(gi => ({ + value: gi.id, + label: gi.name, + icon: ICON_GAMEPAD, + desc: gi.adapter_type, + })), + allowNone: true, + noneLabel: t('common.none_no_input'), + placeholder: t('palette.search'), + }); +} + +let _cssGameMappings: any[] = []; +let _cssGameMappingIconSelects: IconSelect[] = []; +let _cssGamePresetIconSelect: IconSelect | null = null; + +function _destroyCSSGameMappingIconSelects() { + _cssGameMappingIconSelects.forEach(is => is.destroy()); + _cssGameMappingIconSelects = []; +} + +function _hexToRgbCSS(hex: string): number[] { + const m = hex.replace('#', '').match(/.{2}/g); + if (!m) return [255, 0, 0]; + return m.map(c => parseInt(c, 16)); +} + +function _rgbToHexCSS(rgb: number[]): string { + return '#' + rgb.map(c => c.toString(16).padStart(2, '0')).join(''); +} + +function _getCSSGameAvailableEventTypes(): string[] { + const giId = (document.getElementById('css-editor-game-integration') as HTMLSelectElement)?.value; + if (giId) { + const gi = (_cachedGameIntegrations || []).find(g => g.id === giId); + if (gi) { + const adapter = (_cachedGameAdapters || []).find(a => a.adapter_type === gi.adapter_type); + if (adapter && adapter.supported_events.length > 0) return adapter.supported_events; + } + } + return ['kill', 'death', 'health', 'armor', 'round_start', 'round_end', 'bomb_planted', 'bomb_defused', 'assist', 'headshot']; +} + +const _CSS_GE_EFFECT_TYPES: IconSelectItem[] = [ + { value: 'flash', label: 'Flash', icon: `${P.zap}` }, + { value: 'pulse', label: 'Pulse', icon: `${P.activity}` }, + { value: 'sweep', label: 'Sweep', icon: `${P.fastForward}` }, + { value: 'color_shift', label: 'Color Shift', icon: `${P.rainbow}` }, + { value: 'breathing', label: 'Breathing', icon: `${P.heart}` }, +]; + +const _CSS_GE_EVENT_ICONS: Record = { + kill: P.crosshair, death: P.xIcon, health: P.heart, armor: P.shield, + round_start: P.play, round_end: P.square, bomb_planted: P.flame, bomb_defused: P.circleCheck, + assist: P.swords, headshot: P.target, damage: P.zap, gold: P.star, + level_up: P.trendingUp, respawn: P.refreshCw, item_pickup: P.packageIcon, +}; + +function _buildCSSGameEventTypeItems(): IconSelectItem[] { + return _getCSSGameAvailableEventTypes().map(et => ({ + value: et, + label: et, + icon: `${_CSS_GE_EVENT_ICONS[et] || P.circleDot}`, + })); +} + +function _renderCSSGameMappingRow(mapping: any, index: number): string { + const eventTypes = _getCSSGameAvailableEventTypes(); + const eventOptions = eventTypes.map(et => + `` + ).join(''); + const effectOptions = _CSS_GE_EFFECT_TYPES.map(ef => + `` + ).join(''); + const effectLabel = _CSS_GE_EFFECT_TYPES.find(ef => ef.value === mapping.effect_type)?.label || mapping.effect_type; + const hexColor = _rgbToHexCSS(mapping.color || [255, 0, 0]); + + return ` +
+
+ + + ${escapeHtml(mapping.event_type)} + ${escapeHtml(effectLabel)} + + + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
`; +} + +function _wireCSSGameMappingRows(container: HTMLElement) { + container.querySelectorAll('.gi-mapping-header').forEach(header => { + const item = header.closest('.gi-mapping-row') as HTMLElement; + header.addEventListener('click', (e: Event) => { + if ((e.target as HTMLElement).closest('.btn-remove-rule')) return; + item.classList.toggle('gi-mapping-expanded'); + }); + }); + + container.querySelectorAll('.gi-mapping-row').forEach(row => { + const eventSel = row.querySelector('[data-field="event_type"]') as HTMLSelectElement | null; + const effectSel = row.querySelector('[data-field="effect_type"]') as HTMLSelectElement | null; + const colorInput = row.querySelector('input[data-field="color"]') as HTMLInputElement | null; + const summaryEvent = row.querySelector('.gi-mapping-summary-event') as HTMLElement | null; + const summaryEffect = row.querySelector('.gi-mapping-summary-effect') as HTMLElement | null; + const summaryColor = row.querySelector('.gi-mapping-summary-color') as HTMLElement | null; + + if (eventSel) { + const is = new IconSelect({ target: eventSel, items: _buildCSSGameEventTypeItems(), columns: 4 }); + _cssGameMappingIconSelects.push(is); + if (summaryEvent) { + eventSel.addEventListener('change', () => { summaryEvent.textContent = eventSel.value; }); + } + } + if (effectSel) { + const is = new IconSelect({ target: effectSel, items: _CSS_GE_EFFECT_TYPES, columns: 3 }); + _cssGameMappingIconSelects.push(is); + if (summaryEffect) { + effectSel.addEventListener('change', () => { + const label = _CSS_GE_EFFECT_TYPES.find(ef => ef.value === effectSel.value)?.label || effectSel.value; + summaryEffect.textContent = label; + }); + } + } + if (colorInput && summaryColor) { + colorInput.addEventListener('input', () => { summaryColor.style.background = colorInput.value; }); + } + }); +} + +function _renderCSSGameMappings(mappings: any[]) { + _cssGameMappings = [...mappings]; + _destroyCSSGameMappingIconSelects(); + const container = document.getElementById('css-editor-ge-mappings-list'); + if (!container) return; + container.innerHTML = mappings.map((m, i) => _renderCSSGameMappingRow(m, i)).join(''); + _wireCSSGameMappingRows(container); +} + +function _collectCSSGameMappings(): any[] { + const rows = document.querySelectorAll('#css-editor-ge-mappings-list .gi-mapping-row'); + return Array.from(rows).map(row => { + const eventType = (row.querySelector('[data-field="event_type"]') as HTMLSelectElement)?.value || 'kill'; + const effectType = (row.querySelector('[data-field="effect_type"]') as HTMLSelectElement)?.value || 'flash'; + const colorInput = (row.querySelector('input[data-field="color"]') as HTMLInputElement)?.value || '#ff0000'; + const duration = parseFloat((row.querySelector('[data-field="duration_ms"]') as HTMLInputElement)?.value) || 500; + const intensity = parseFloat((row.querySelector('[data-field="intensity"]') as HTMLInputElement)?.value) || 1.0; + const priority = parseInt((row.querySelector('[data-field="priority"]') as HTMLInputElement)?.value) || 5; + return { event_type: eventType, effect_type: effectType, color: _hexToRgbCSS(colorInput), duration_ms: duration, intensity, priority }; + }); +} + +export function addCSSGameMapping() { + const collected = _collectCSSGameMappings(); + collected.push({ + event_type: _getCSSGameAvailableEventTypes()[0] || 'kill', + effect_type: 'flash', + color: [255, 0, 0], + duration_ms: 500, + intensity: 1.0, + priority: 5, + }); + _renderCSSGameMappings(collected); +} + +export function removeCSSGameMapping(index: number) { + const collected = _collectCSSGameMappings(); + collected.splice(index, 1); + _renderCSSGameMappings(collected); +} + +export function onCSSGameMappingPresetChange() { + const sel = document.getElementById('css-editor-ge-mapping-preset') as HTMLSelectElement; + if (!sel.value) return; + const presets: Record = { + fps_combat: [ + { event_type: 'kill', effect_type: 'flash', color: [0, 255, 0], duration_ms: 400, intensity: 1.0, priority: 8 }, + { event_type: 'death', effect_type: 'pulse', color: [255, 0, 0], duration_ms: 1500, intensity: 1.0, priority: 10 }, + { event_type: 'headshot', effect_type: 'flash', color: [255, 215, 0], duration_ms: 300, intensity: 1.0, priority: 9 }, + { event_type: 'health', effect_type: 'breathing', color: [255, 50, 50], duration_ms: 2000, intensity: 0.6, priority: 3 }, + { event_type: 'round_start', effect_type: 'sweep', color: [0, 100, 255], duration_ms: 800, intensity: 0.8, priority: 5 }, + ], + moba_health: [ + { event_type: 'health', effect_type: 'color_shift', color: [0, 255, 0], duration_ms: 1000, intensity: 0.7, priority: 4 }, + { event_type: 'kill', effect_type: 'flash', color: [255, 215, 0], duration_ms: 500, intensity: 1.0, priority: 8 }, + { event_type: 'death', effect_type: 'pulse', color: [255, 0, 0], duration_ms: 2000, intensity: 1.0, priority: 10 }, + { event_type: 'assist', effect_type: 'flash', color: [100, 200, 255], duration_ms: 300, intensity: 0.8, priority: 6 }, + ], + }; + const preset = presets[sel.value]; + if (preset) _renderCSSGameMappings(preset); + sel.value = ''; +} + +function _initCSSGamePresetIconSelect() { + const sel = document.getElementById('css-editor-ge-mapping-preset') as HTMLSelectElement | null; + if (!sel) return; + if (_cssGamePresetIconSelect) { _cssGamePresetIconSelect.destroy(); _cssGamePresetIconSelect = null; } + const items: IconSelectItem[] = [ + { value: '', label: t('game_integration.mapping.select_preset'), icon: '' }, + { value: 'fps_combat', label: t('game_integration.preset.fps_combat'), icon: `${P.crosshair}` }, + { value: 'moba_health', label: t('game_integration.preset.moba_health'), icon: `${P.heart}` }, + ]; + _cssGamePresetIconSelect = new IconSelect({ target: sel, items, columns: 2 }); +} + function _ensureAudioSensitivityWidget(): BindableScalarWidget { if (!_audioSensitivityWidget) { _audioSensitivityWidget = new BindableScalarWidget({ @@ -2107,6 +2382,31 @@ const _typeHandlers: Record any; reset: (... }; }, }, + game_event: { + load(css: any) { + _populateGameIntegrationDropdownCSS(css.game_integration_id || ''); + _ensureGameEventIdleColorWidget().setValue(css.idle_color); + _renderCSSGameMappings(css.event_mappings || []); + }, + reset() { + _populateGameIntegrationDropdownCSS(''); + _ensureGameEventIdleColorWidget().setValue([0, 0, 0]); + _renderCSSGameMappings([]); + }, + getPayload(name: any) { + const giId = (document.getElementById('css-editor-game-integration') as HTMLSelectElement).value; + if (!giId) { + cssEditorModal.showError(t('color_strip.game_event.error.no_integration')); + return null; + } + return { + name, + game_integration_id: giId, + idle_color: _ensureGameEventIdleColorWidget().getValue(), + event_mappings: _collectCSSGameMappings(), + }; + }, + }, }; /* ── Editor open/close ────────────────────────────────────────── */ diff --git a/server/src/wled_controller/static/js/features/dashboard.ts b/server/src/wled_controller/static/js/features/dashboard.ts index b8304f0..9992443 100644 --- a/server/src/wled_controller/static/js/features/dashboard.ts +++ b/server/src/wled_controller/static/js/features/dashboard.ts @@ -649,18 +649,18 @@ function renderDashboardAutomation(automation: Automation, sceneMap: Map 0) { - const parts = automation.conditions.map(c => { - if (c.condition_type === 'application') { - const apps = (c.apps || []).join(', '); - const matchLabel = c.match_type === 'topmost' ? t('automations.condition.application.match_type.topmost') : t('automations.condition.application.match_type.running'); + if (automation.rules.length > 0) { + const parts = automation.rules.map(r => { + if (r.rule_type === 'application') { + const apps = (r.apps || []).join(', '); + const matchLabel = r.match_type === 'topmost' ? t('automations.rule.application.match_type.topmost') : t('automations.rule.application.match_type.running'); return `${apps} (${matchLabel})`; } - if (c.condition_type === 'startup') return t('automations.condition.startup'); - if (c.condition_type === 'time_of_day') return t('automations.condition.time_of_day'); - return t(`automations.condition.${c.condition_type}`) || c.condition_type; + if (r.rule_type === 'startup') return t('automations.rule.startup'); + if (r.rule_type === 'time_of_day') return t('automations.rule.time_of_day'); + return t(`automations.rule.${r.rule_type}`) || r.rule_type; }); - const logic = automation.condition_logic === 'and' ? ' & ' : ' | '; + const logic = automation.rule_logic === 'and' ? ' & ' : ' | '; condSummary = parts.join(logic); } diff --git a/server/src/wled_controller/static/js/features/game-integration.ts b/server/src/wled_controller/static/js/features/game-integration.ts new file mode 100644 index 0000000..a13c066 --- /dev/null +++ b/server/src/wled_controller/static/js/features/game-integration.ts @@ -0,0 +1,761 @@ +/** + * Game Integration — CRUD, cards, modal handlers, live event monitor. + */ + +import { + gameIntegrationsCache, gameAdaptersCache, + _cachedGameIntegrations, _cachedGameAdapters, +} from '../core/state.ts'; +import { fetchWithAuth, escapeHtml } from '../core/api.ts'; +import { t } from '../core/i18n.ts'; +import { Modal } from '../core/modal.ts'; +import { showToast, showConfirm } from '../core/ui.ts'; +import { CardSection } from '../core/card-sections.ts'; +import { wrapCard } from '../core/card-colors.ts'; +import { TagInput, renderTagChips } from '../core/tag-input.ts'; +import { IconSelect, type IconSelectItem } from '../core/icon-select.ts'; +import { + ICON_GAMEPAD, ICON_CLONE, ICON_EDIT, ICON_TEST, ICON_TRASH, + getGameAdapterIcon, ICON_CIRCLE_DOT, +} from '../core/icons.ts'; +import * as P from '../core/icon-paths.ts'; +import type { + GameIntegration, GameAdapterInfo, GameEventMapping, GameEventRecord, GameIntegrationStatus, + EffectPreset, +} from '../types.ts'; + +const _icon = (d: string) => `${d}`; + +// ── Bulk actions ── + +function _bulkDeleteGameIntegrations(ids: string[]) { + return Promise.allSettled(ids.map(id => + fetchWithAuth(`/game-integrations/${id}`, { method: 'DELETE' }) + )).then(results => { + const failed = results.filter(r => r.status === 'rejected' || ((r as any).value && !(r as any).value.ok)).length; + if (failed) showToast(`${ids.length - failed}/${ids.length} deleted`, 'warning'); + else showToast(t('game_integration.deleted'), 'success'); + gameIntegrationsCache.invalidate(); + loadGameIntegrations(); + }); +} + +const _gameIntegrationBulkActions = [{ + key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', + confirm: 'bulk.confirm_delete', handler: _bulkDeleteGameIntegrations, +}]; + +// ── CardSection ── + +export const csGameIntegrations = new CardSection('game-integrations', { + titleKey: 'game_integration.section_title', + gridClass: 'templates-grid', + addCardOnclick: "showGameIntegrationEditor()", + keyAttr: 'data-gi-id', + emptyKey: 'section.empty.game_integrations', + bulkActions: _gameIntegrationBulkActions, +}); + +// ── Modal ── + +let _giTagsInput: TagInput | null = null; +let _adapterTypeIconSelect: IconSelect | null = null; +let _mappingIconSelects: IconSelect[] = []; +let _presetIconSelect: IconSelect | null = null; +let _eventMonitorTimer: ReturnType | null = null; + +class GameIntegrationModal extends Modal { + constructor() { super('game-integration-modal'); } + + snapshotValues() { + return { + name: (this.$('gi-name') as HTMLInputElement)?.value || '', + description: (this.$('gi-description') as HTMLInputElement)?.value || '', + adapterType: (this.$('gi-adapter-type') as HTMLSelectElement)?.value || '', + enabled: (this.$('gi-enabled') as HTMLInputElement)?.checked ? '1' : '0', + mappings: JSON.stringify(_collectMappings()), + tags: JSON.stringify(_giTagsInput ? _giTagsInput.getValue() : []), + config: JSON.stringify(_collectAdapterConfig()), + }; + } + + onForceClose() { + if (_giTagsInput) { _giTagsInput.destroy(); _giTagsInput = null; } + if (_adapterTypeIconSelect) { _adapterTypeIconSelect.destroy(); _adapterTypeIconSelect = null; } + if (_presetIconSelect) { _presetIconSelect.destroy(); _presetIconSelect = null; } + _destroyMappingIconSelects(); + _stopEventMonitor(); + } +} + +const giModal = new GameIntegrationModal(); + +// ── Adapter config helpers ── + +function _collectAdapterConfig(): Record { + const container = document.getElementById('gi-adapter-config-fields'); + if (!container) return {}; + const config: Record = {}; + container.querySelectorAll('[data-config-key]').forEach(el => { + const key = (el as HTMLElement).dataset.configKey!; + if (el instanceof HTMLInputElement) { + if (el.type === 'number') config[key] = parseFloat(el.value) || 0; + else if (el.type === 'checkbox') config[key] = el.checked; + else config[key] = el.value; + } + }); + return config; +} + +function _renderAdapterConfigFields(adapter: GameAdapterInfo, existingConfig: Record = {}) { + const container = document.getElementById('gi-adapter-config-fields')!; + if (!adapter.config_schema || adapter.config_schema.length === 0) { + container.innerHTML = `

${t('game_integration.no_config')}

`; + return; + } + container.innerHTML = adapter.config_schema.map(field => { + const val = existingConfig[field.name] ?? field.default ?? ''; + const inputType = field.type === 'number' ? 'number' : field.type === 'boolean' ? 'checkbox' : 'text'; + const checked = field.type === 'boolean' && val ? ' checked' : ''; + const inputVal = field.type === 'boolean' ? '' : ` value="${escapeHtml(String(val))}"`; + return ` +
+
+ + ${field.hint ? `` : ''} +
+ ${field.hint ? `` : ''} + +
`; + }).join(''); +} + +let _currentSetupInstructions = ''; +let _currentAdapterSupportsAutoSetup = false; + +function _renderSetupInstructions(adapter: GameAdapterInfo) { + const btnWrapper = document.getElementById('gi-setup-instructions-btn-wrapper')!; + _currentSetupInstructions = adapter.setup_instructions || ''; + _currentAdapterSupportsAutoSetup = adapter.supports_auto_setup || false; + const visible = _currentSetupInstructions || _currentAdapterSupportsAutoSetup; + btnWrapper.style.display = visible ? 'flex' : 'none'; + btnWrapper.style.gap = '0.5rem'; + + const autoSetupBtn = document.getElementById('gi-auto-setup-btn'); + if (autoSetupBtn) { + autoSetupBtn.style.display = _currentAdapterSupportsAutoSetup ? '' : 'none'; + } +} + +export function openSetupInstructions() { + if (!_currentSetupInstructions) return; + const overlay = document.getElementById('gi-setup-overlay'); + const content = document.getElementById('gi-setup-overlay-content'); + if (overlay && content) { + import('marked').then(({ marked }) => { + content.innerHTML = marked.parse(_currentSetupInstructions) as string; + overlay.style.display = 'flex'; + }); + } +} + +export function closeSetupInstructions() { + const overlay = document.getElementById('gi-setup-overlay'); + if (overlay) overlay.style.display = 'none'; +} + +export async function autoSetupGameIntegration() { + const id = (document.getElementById('gi-id') as HTMLInputElement)?.value; + if (!id) { + showToast(t('game_integration.auto_setup.save_first'), 'warning'); + return; + } + + try { + const res = await fetchWithAuth(`/game-integrations/${id}/auto-setup`, { method: 'POST' }); + if (!res || !res.ok) { + const err = await res!.json(); + showToast(err.detail || t('game_integration.auto_setup.failed'), 'error'); + return; + } + const data = await res.json(); + if (data.success) { + let msg = t('game_integration.auto_setup.success'); + if (data.file_path) msg += `\n${data.file_path}`; + if (data.token_generated) msg += `\n${t('game_integration.auto_setup.token_generated')}`; + showToast(msg, 'success'); + + // Reload integration data in case auth token was generated + if (data.token_generated) { + gameIntegrationsCache.invalidate(); + const integrations = await gameIntegrationsCache.fetch(); + const gi = integrations.find(g => g.id === id); + if (gi) { + const adapters = await gameAdaptersCache.fetch(); + const adapter = adapters.find(a => a.adapter_type === gi.adapter_type); + if (adapter) _renderAdapterConfigFields(adapter, gi.adapter_config || {}); + } + } + } else { + showToast(data.message || t('game_integration.auto_setup.failed'), 'error'); + } + } catch (e: any) { + if (e.isAuth) return; + showToast(e.message || t('game_integration.auto_setup.failed'), 'error'); + } +} + +// ── Event mapping helpers ── + +let _currentMappings: GameEventMapping[] = []; + +function _collectMappings(): GameEventMapping[] { + const rows = document.querySelectorAll('#gi-mappings-list .gi-mapping-row'); + return Array.from(rows).map(row => { + const eventType = (row.querySelector('[data-field="event_type"]') as HTMLSelectElement)?.value || 'kill'; + const effectType = (row.querySelector('[data-field="effect_type"]') as HTMLSelectElement)?.value || 'flash'; + const colorInput = (row.querySelector('input[data-field="color"]') as HTMLInputElement)?.value || '#ff0000'; + const duration = parseFloat((row.querySelector('[data-field="duration_ms"]') as HTMLInputElement)?.value) || 500; + const intensity = parseFloat((row.querySelector('[data-field="intensity"]') as HTMLInputElement)?.value) || 1.0; + const priority = parseInt((row.querySelector('[data-field="priority"]') as HTMLInputElement)?.value) || 5; + const rgb = _hexToRgb(colorInput); + return { event_type: eventType, effect_type: effectType, color: rgb, duration_ms: duration, intensity, priority }; + }); +} + +function _hexToRgb(hex: string): number[] { + const m = hex.replace('#', '').match(/.{2}/g); + if (!m) return [255, 0, 0]; + return m.map(c => parseInt(c, 16)); +} + +function _rgbToHex(rgb: number[]): string { + return '#' + rgb.map(c => c.toString(16).padStart(2, '0')).join(''); +} + +function _destroyMappingIconSelects() { + _mappingIconSelects.forEach(is => is.destroy()); + _mappingIconSelects = []; +} + +const EFFECT_TYPES: IconSelectItem[] = [ + { value: 'flash', label: 'Flash', icon: _icon(P.zap) }, + { value: 'pulse', label: 'Pulse', icon: _icon(P.activity) }, + { value: 'sweep', label: 'Sweep', icon: _icon(P.fastForward) }, + { value: 'color_shift', label: 'Color Shift', icon: _icon(P.rainbow) }, + { value: 'breathing', label: 'Breathing', icon: _icon(P.heart) }, +]; + +/** Map well-known game event types to icons. Falls back to a generic icon. */ +const _EVENT_TYPE_ICONS: Record = { + kill: P.crosshair, death: P.xIcon, health: P.heart, armor: P.shield, + round_start: P.play, round_end: P.square, bomb_planted: P.flame, bomb_defused: P.circleCheck, + assist: P.swords, headshot: P.target, damage: P.zap, gold: P.star, + level_up: P.trendingUp, respawn: P.refreshCw, item_pickup: P.packageIcon, +}; + +function _buildEventTypeItems(): IconSelectItem[] { + return _getAvailableEventTypes().map(et => ({ + value: et, + label: et, + icon: _icon(_EVENT_TYPE_ICONS[et] || P.circleDot), + })); +} + +function _getAvailableEventTypes(): string[] { + const adapterType = (document.getElementById('gi-adapter-type') as HTMLSelectElement)?.value; + const adapter = _cachedGameAdapters.find(a => a.adapter_type === adapterType); + if (adapter && adapter.supported_events.length > 0) return adapter.supported_events; + return ['kill', 'death', 'health', 'armor', 'round_start', 'round_end', 'bomb_planted', 'bomb_defused', 'assist', 'headshot']; +} + +function _renderMappingRow(mapping: GameEventMapping, index: number): string { + const eventTypes = _getAvailableEventTypes(); + const eventOptions = eventTypes.map(et => + `` + ).join(''); + const effectOptions = EFFECT_TYPES.map(ef => + `` + ).join(''); + const effectLabel = EFFECT_TYPES.find(ef => ef.value === mapping.effect_type)?.label || mapping.effect_type; + const hexColor = _rgbToHex(mapping.color); + + return ` +
+
+ + + ${escapeHtml(mapping.event_type)} + ${escapeHtml(effectLabel)} + + + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
`; +} + +function _renderMappings(mappings: GameEventMapping[]) { + _currentMappings = [...mappings]; + const container = document.getElementById('gi-mappings-list')!; + _destroyMappingIconSelects(); + container.innerHTML = mappings.map((m, i) => _renderMappingRow(m, i)).join(''); + _wireMappingRows(container); +} + +function _wireMappingRows(container: HTMLElement) { + // Expand/collapse on header click + container.querySelectorAll('.gi-mapping-header').forEach(header => { + const item = header.closest('.gi-mapping-row') as HTMLElement; + header.addEventListener('click', (e: Event) => { + const target = e.target as HTMLElement; + if (target.closest('.btn-remove-rule')) return; + item.classList.toggle('gi-mapping-expanded'); + }); + }); + + // Wire IconSelect + summary sync on each row + container.querySelectorAll('.gi-mapping-row').forEach(row => { + const eventSel = row.querySelector('[data-field="event_type"]') as HTMLSelectElement | null; + const effectSel = row.querySelector('[data-field="effect_type"]') as HTMLSelectElement | null; + const colorInput = row.querySelector('input[data-field="color"]') as HTMLInputElement | null; + const summaryEvent = row.querySelector('.gi-mapping-summary-event') as HTMLElement | null; + const summaryEffect = row.querySelector('.gi-mapping-summary-effect') as HTMLElement | null; + const summaryColor = row.querySelector('.gi-mapping-summary-color') as HTMLElement | null; + + // Event type IconSelect + if (eventSel) { + const is = new IconSelect({ target: eventSel, items: _buildEventTypeItems(), columns: 4 }); + _mappingIconSelects.push(is); + if (summaryEvent) { + eventSel.addEventListener('change', () => { summaryEvent.textContent = eventSel.value; }); + } + } + + // Effect type IconSelect + if (effectSel) { + const is = new IconSelect({ target: effectSel, items: EFFECT_TYPES, columns: 3 }); + _mappingIconSelects.push(is); + if (summaryEffect) { + effectSel.addEventListener('change', () => { + const label = EFFECT_TYPES.find(ef => ef.value === effectSel.value)?.label || effectSel.value; + summaryEffect.textContent = label; + }); + } + } + + // Color swatch sync + if (colorInput && summaryColor) { + colorInput.addEventListener('input', () => { summaryColor.style.background = colorInput.value; }); + } + }); +} + +export function addGameMapping() { + const newMapping: GameEventMapping = { + event_type: _getAvailableEventTypes()[0] || 'kill', + effect_type: 'flash', + color: [255, 0, 0], + duration_ms: 500, + intensity: 1.0, + priority: 5, + }; + const collected = _collectMappings(); + collected.push(newMapping); + _renderMappings(collected); +} + +export function removeGameMapping(index: number) { + const collected = _collectMappings(); + collected.splice(index, 1); + _renderMappings(collected); +} + +let _cachedPresets: EffectPreset[] = []; + +async function _loadPresets(): Promise { + if (_cachedPresets.length > 0) return _cachedPresets; + try { + const res = await fetchWithAuth('/game-integrations/presets'); + if (res && res.ok) { + const data = await res.json(); + _cachedPresets = data.presets || []; + } + } catch { /* ignore */ } + return _cachedPresets; +} + +function _applyMappingPreset(presetKey: string) { + const preset = _cachedPresets.find(p => p.key === presetKey); + if (!preset) return; + // Map API effect field to frontend effect_type field + const mappings: GameEventMapping[] = preset.event_mappings.map(m => ({ + event_type: m.event_type, + effect_type: (m as any).effect || (m as any).effect_type || 'flash', + color: m.color, + duration_ms: m.duration_ms, + intensity: m.intensity, + priority: m.priority, + })); + _renderMappings(mappings); +} + +export function onMappingPresetChange() { + const sel = document.getElementById('gi-mapping-preset') as HTMLSelectElement; + if (sel.value) { + _applyMappingPreset(sel.value); + sel.value = ''; + } +} + +async function _populatePresetSelector() { + const sel = document.getElementById('gi-mapping-preset') as HTMLSelectElement; + if (!sel) return; + if (_presetIconSelect) { _presetIconSelect.destroy(); _presetIconSelect = null; } + const presets = await _loadPresets(); + sel.innerHTML = `` + + presets.map(p => ``).join(''); + if (presets.length > 0) { + const items: IconSelectItem[] = [ + { value: '', label: t('game_integration.mapping.select_preset'), icon: '' }, + ...presets.map(p => ({ + value: p.key, + label: p.name, + icon: _icon(P.sparkles), + desc: p.description, + })), + ]; + _presetIconSelect = new IconSelect({ target: sel, items, columns: 2 }); + } +} + +// ── Live event monitor ── + +function _stopEventMonitor() { + if (_eventMonitorTimer) { + clearInterval(_eventMonitorTimer); + _eventMonitorTimer = null; + } +} + +function _startEventMonitor(integrationId: string) { + _stopEventMonitor(); + const feed = document.getElementById('gi-event-feed'); + if (!feed) return; + feed.innerHTML = `
${t('game_integration.events.waiting')}
`; + + const poll = async () => { + try { + const res = await fetchWithAuth(`/game-integrations/${integrationId}/events`); + if (!res || !res.ok) return; + const data = await res.json(); + const events: GameEventRecord[] = data.events || []; + if (events.length === 0) return; + feed.innerHTML = events.slice(0, 20).map(ev => { + const ts = new Date(ev.timestamp).toLocaleTimeString(); + const valStr = ev.value !== undefined ? ` = ${ev.value}` : ''; + return `
+ ${ts} + ${escapeHtml(ev.event_type)} + ${valStr} +
`; + }).join(''); + } catch { /* ignore polling errors */ } + }; + + poll(); + _eventMonitorTimer = setInterval(poll, 2000); +} + +// ── Connection test ── + +let _connectionTestTimer: ReturnType | null = null; + +export function testGameConnection() { + const id = (document.getElementById('gi-id') as HTMLInputElement)?.value; + if (!id) { + showToast(t('game_integration.error.save_first'), 'warning'); + return; + } + const panel = document.getElementById('gi-test-panel')!; + panel.style.display = ''; + panel.innerHTML = `
${ICON_CIRCLE_DOT} ${t('game_integration.test.waiting')}
`; + + if (_connectionTestTimer) clearInterval(_connectionTestTimer); + + let attempts = 0; + _connectionTestTimer = setInterval(async () => { + attempts++; + try { + const res = await fetchWithAuth(`/game-integrations/${id}/status`); + if (!res || !res.ok) return; + const status: GameIntegrationStatus = await res.json(); + if (status.event_count > 0) { + clearInterval(_connectionTestTimer!); + _connectionTestTimer = null; + panel.innerHTML = `
${t('game_integration.test.success')} (${status.event_count})
`; + } else if (status.error) { + clearInterval(_connectionTestTimer!); + _connectionTestTimer = null; + panel.innerHTML = `
${t('game_integration.test.error')}: ${escapeHtml(status.error)}
`; + } + } catch { /* ignore */ } + if (attempts >= 30) { + clearInterval(_connectionTestTimer!); + _connectionTestTimer = null; + panel.innerHTML = `
${t('game_integration.test.timeout')}
`; + } + }, 2000); +} + +// ── Card renderer ── + +export function createGameIntegrationCard(gi: GameIntegration): string { + const adapterIcon = getGameAdapterIcon(gi.adapter_type); + const adapterName = _cachedGameAdapters.find(a => a.adapter_type === gi.adapter_type)?.display_name || gi.adapter_type; + const enabledClass = gi.enabled ? 'gi-status-active' : 'gi-status-inactive'; + const enabledLabel = gi.enabled ? t('game_integration.status.active') : t('game_integration.status.inactive'); + const mappingCount = gi.event_mappings?.length || 0; + + return wrapCard({ + type: 'template-card', + dataAttr: 'data-gi-id', + id: gi.id, + removeOnclick: `deleteGameIntegration('${gi.id}')`, + removeTitle: t('common.delete'), + content: ` +
+
${adapterIcon} ${escapeHtml(gi.name)}
+
+ ${gi.description ? `
${escapeHtml(gi.description)}
` : ''} +
+ ${ICON_GAMEPAD} ${escapeHtml(adapterName)} + ${ICON_CIRCLE_DOT} ${enabledLabel} + ${mappingCount > 0 ? `${_icon(P.listChecks)} ${mappingCount}` : ''} +
+ ${renderTagChips(gi.tags)}`, + actions: ` + + + `, + }); +} + +// ── CRUD ── + +export async function showGameIntegrationEditor(editId: string | null = null) { + const titleEl = document.getElementById('gi-title')!; + const idInput = document.getElementById('gi-id') as HTMLInputElement; + const nameInput = document.getElementById('gi-name') as HTMLInputElement; + const descInput = document.getElementById('gi-description') as HTMLInputElement; + const adapterSel = document.getElementById('gi-adapter-type') as HTMLSelectElement; + const enabledCheck = document.getElementById('gi-enabled') as HTMLInputElement; + const testPanel = document.getElementById('gi-test-panel')!; + + // Reset form + idInput.value = ''; + nameInput.value = ''; + descInput.value = ''; + enabledCheck.checked = true; + testPanel.style.display = 'none'; + document.getElementById('gi-error')!.style.display = 'none'; + + // Ensure adapters are loaded + const adapters = await gameAdaptersCache.fetch(); + adapterSel.innerHTML = adapters.map(a => + `` + ).join(''); + + // Setup adapter type IconSelect + if (_adapterTypeIconSelect) { _adapterTypeIconSelect.destroy(); _adapterTypeIconSelect = null; } + const adapterItems: IconSelectItem[] = adapters.map(a => ({ + value: a.adapter_type, + label: a.display_name, + icon: getGameAdapterIcon(a.adapter_type), + desc: a.game_name, + })); + _adapterTypeIconSelect = new IconSelect({ + target: adapterSel, + items: adapterItems, + columns: 3, + }); + + // Tags + if (_giTagsInput) { _giTagsInput.destroy(); _giTagsInput = null; } + _giTagsInput = new TagInput(document.getElementById('gi-tags-container')!); + + if (editId) { + const integrations = await gameIntegrationsCache.fetch(); + const gi = integrations.find(g => g.id === editId); + if (!gi) return; + idInput.value = gi.id; + nameInput.value = gi.name; + descInput.value = gi.description || ''; + adapterSel.value = gi.adapter_type; + if (_adapterTypeIconSelect) _adapterTypeIconSelect.setValue(gi.adapter_type); + enabledCheck.checked = gi.enabled; + _giTagsInput.setValue(gi.tags || []); + + const adapter = adapters.find(a => a.adapter_type === gi.adapter_type); + if (adapter) { + _renderAdapterConfigFields(adapter, gi.adapter_config || {}); + _renderSetupInstructions(adapter); + } + _renderMappings(gi.event_mappings || []); + + titleEl.innerHTML = `${ICON_GAMEPAD} ${t('game_integration.edit')}`; + + // Start event monitor for existing integration + _startEventMonitor(gi.id); + } else { + titleEl.innerHTML = `${ICON_GAMEPAD} ${t('game_integration.add')}`; + _renderMappings([]); + + // Show config for first adapter + if (adapters.length > 0) { + _renderAdapterConfigFields(adapters[0]); + _renderSetupInstructions(adapters[0]); + } + } + + // Listen for adapter type changes + adapterSel.onchange = () => { + const adapter = adapters.find(a => a.adapter_type === adapterSel.value); + if (adapter) { + _renderAdapterConfigFields(adapter); + _renderSetupInstructions(adapter); + // Re-render mappings to update available event types + _renderMappings(_collectMappings()); + } + }; + + // Populate preset selector from API + await _populatePresetSelector(); + + giModal.open(); + giModal.snapshot(); +} + +export async function saveGameIntegration() { + const id = (document.getElementById('gi-id') as HTMLInputElement).value; + const name = (document.getElementById('gi-name') as HTMLInputElement).value.trim(); + if (!name) { giModal.showError(t('game_integration.error.name_required')); return; } + + const adapterType = (document.getElementById('gi-adapter-type') as HTMLSelectElement).value; + const description = (document.getElementById('gi-description') as HTMLInputElement).value.trim(); + const enabled = (document.getElementById('gi-enabled') as HTMLInputElement).checked; + const adapterConfig = _collectAdapterConfig(); + const eventMappings = _collectMappings(); + const tags = _giTagsInput ? _giTagsInput.getValue() : []; + + const payload = { + name, adapter_type: adapterType, adapter_config: adapterConfig, + event_mappings: eventMappings, enabled, description, tags, + }; + + try { + const url = id ? `/game-integrations/${id}` : '/game-integrations'; + const method = id ? 'PUT' : 'POST'; + const res = await fetchWithAuth(url, { method, body: JSON.stringify(payload) }); + if (!res || !res.ok) { + const err = await res!.json(); + throw new Error(err.detail || t('game_integration.error.save_failed')); + } + showToast(id ? t('game_integration.updated') : t('game_integration.created'), 'success'); + gameIntegrationsCache.invalidate(); + giModal.forceClose(); + loadGameIntegrations(); + } catch (e: any) { + if (e.isAuth) return; + giModal.showError(e.message); + } +} + +export async function deleteGameIntegration(entityId: string) { + const ok = await showConfirm(t('game_integration.confirm_delete')); + if (!ok) return; + try { + await fetchWithAuth(`/game-integrations/${entityId}`, { method: 'DELETE' }); + showToast(t('game_integration.deleted'), 'success'); + gameIntegrationsCache.invalidate(); + loadGameIntegrations(); + } catch (e: any) { + if (e.isAuth) return; + showToast(e.message || t('game_integration.error.delete_failed'), 'error'); + } +} + +export async function cloneGameIntegration(entityId: string) { + const integrations = await gameIntegrationsCache.fetch(); + const source = integrations.find(g => g.id === entityId); + if (!source) return; + await showGameIntegrationEditor(null); + (document.getElementById('gi-name') as HTMLInputElement).value = source.name + ' (Copy)'; + (document.getElementById('gi-description') as HTMLInputElement).value = source.description || ''; + const adapterSel = document.getElementById('gi-adapter-type') as HTMLSelectElement; + adapterSel.value = source.adapter_type; + if (_adapterTypeIconSelect) _adapterTypeIconSelect.setValue(source.adapter_type); + (document.getElementById('gi-enabled') as HTMLInputElement).checked = source.enabled; + if (_giTagsInput) _giTagsInput.setValue(source.tags || []); + + const adapter = _cachedGameAdapters.find(a => a.adapter_type === source.adapter_type); + if (adapter) { + _renderAdapterConfigFields(adapter, source.adapter_config || {}); + _renderSetupInstructions(adapter); + } + _renderMappings(source.event_mappings || []); + giModal.snapshot(); +} + +export function closeGameIntegrationModal() { + giModal.close(); +} + +// ── Event monitor (standalone, triggered from card) ── + +export function showGameEventMonitor(integrationId: string) { + const gi = _cachedGameIntegrations.find(g => g.id === integrationId); + if (!gi) return; + // Open editor and start monitoring + showGameIntegrationEditor(integrationId); +} + +// ── Load function (called from streams.ts) ── + +export async function loadGameIntegrations() { + await Promise.all([ + gameIntegrationsCache.fetch(), + gameAdaptersCache.fetch(), + ]); + // Streams.ts handles rendering via its own renderPictureSourcesList + if (window.loadPictureSources) window.loadPictureSources(); +} diff --git a/server/src/wled_controller/static/js/features/streams.ts b/server/src/wled_controller/static/js/features/streams.ts index 5e7d8df..97feed2 100644 --- a/server/src/wled_controller/static/js/features/streams.ts +++ b/server/src/wled_controller/static/js/features/streams.ts @@ -39,6 +39,8 @@ import { colorStripSourcesCache, csptCache, stripFiltersCache, gradientsCache, GradientEntity, + gameIntegrationsCache, gameAdaptersCache, + _cachedGameIntegrations, _cachedGameAdapters, } from '../core/state.ts'; import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.ts'; import { t } from '../core/i18n.ts'; @@ -55,12 +57,14 @@ import { createHASourceCard, initHASourceDelegation } from './home-assistant-sou import { createAssetCard, initAssetDelegation } from './assets.ts'; import { createColorStripCard } from './color-strips.ts'; import { initAudioSourceDelegation } from './audio-sources.ts'; +import { createGameIntegrationCard, csGameIntegrations } from './game-integration.ts'; import { getEngineIcon, getAudioEngineIcon, getPictureSourceIcon, getAudioSourceIcon, getColorStripIcon, ICON_TEMPLATE, ICON_CLONE, ICON_EDIT, ICON_TEST, ICON_LINK_SOURCE, ICON_FPS, ICON_WEB, ICON_VALUE_SOURCE, ICON_CLOCK, ICON_AUDIO_LOOPBACK, ICON_AUDIO_INPUT, ICON_AUDIO_TEMPLATE, ICON_MONITOR, ICON_WRENCH, ICON_RADIO, ICON_ACTIVITY, ICON_CAPTURE_TEMPLATE, ICON_PP_TEMPLATE, ICON_CSPT, ICON_HELP, ICON_TRASH, ICON_PALETTE, ICON_ASSET, + ICON_GAMEPAD, getAssetTypeIcon, } from '../core/icons.ts'; import * as P from '../core/icon-paths.ts'; @@ -292,6 +296,8 @@ export async function loadPictureSources() { colorStripSourcesCache.fetch(), csptCache.fetch(), gradientsCache.fetch(), + gameIntegrationsCache.fetch(), + gameAdaptersCache.fetch(), filtersCache.data.length === 0 ? filtersCache.fetch() : Promise.resolve(filtersCache.data), ]); renderPictureSourcesList(streams); @@ -346,6 +352,7 @@ const _streamSectionMap = { sync: [csSyncClocks], weather: [csWeatherSources], home_assistant: [csHASources], + game: [csGameIntegrations], }; type StreamCardRenderer = (stream: any) => string; @@ -574,6 +581,7 @@ function renderPictureSourcesList(streams: any) { { key: 'weather', icon: `${P.cloudSun}`, titleKey: 'streams.group.weather', count: _cachedWeatherSources.length }, { key: 'home_assistant', icon: `${P.home}`, titleKey: 'streams.group.home_assistant', count: _cachedHASources.length }, { key: 'assets', icon: ICON_ASSET, titleKey: 'streams.group.assets', count: _cachedAssets.length }, + { key: 'game', icon: ICON_GAMEPAD, titleKey: 'streams.group.game', count: _cachedGameIntegrations.length }, ]; // Build tree navigation structure @@ -626,6 +634,7 @@ function renderPictureSourcesList(streams: any) { children: [ { key: 'weather', titleKey: 'streams.group.weather', icon: `${P.cloudSun}`, count: _cachedWeatherSources.length }, { key: 'home_assistant', titleKey: 'streams.group.home_assistant', icon: `${P.home}`, count: _cachedHASources.length }, + { key: 'game', titleKey: 'streams.group.game', icon: ICON_GAMEPAD, count: _cachedGameIntegrations.length }, ] }, { @@ -797,6 +806,7 @@ function renderPictureSourcesList(streams: any) { const haSourceItems = csHASources.applySortOrder(_cachedHASources.map(s => ({ key: s.id, html: createHASourceCard(s) }))); const assetItems = csAssets.applySortOrder(_cachedAssets.map(a => ({ key: a.id, html: createAssetCard(a) }))); const csptItems = csCSPTemplates.applySortOrder(csptTemplates.map(t => ({ key: t.id, html: renderCSPTCard(t) }))); + const gameIntegrationItems = csGameIntegrations.applySortOrder(_cachedGameIntegrations.map(g => ({ key: g.id, html: createGameIntegrationCard(g) }))); if (csRawStreams.isMounted()) { // Incremental update: reconcile cards in-place @@ -817,6 +827,7 @@ function renderPictureSourcesList(streams: any) { weather: _cachedWeatherSources.length, home_assistant: _cachedHASources.length, assets: _cachedAssets.length, + game: _cachedGameIntegrations.length, }); csRawStreams.reconcile(rawStreamItems); csRawTemplates.reconcile(rawTemplateItems); @@ -836,6 +847,7 @@ function renderPictureSourcesList(streams: any) { csWeatherSources.reconcile(weatherSourceItems); csHASources.reconcile(haSourceItems); csAssets.reconcile(assetItems); + csGameIntegrations.reconcile(gameIntegrationItems); } else { // First render: build full HTML const panels = tabs.map(tab => { @@ -856,13 +868,14 @@ function renderPictureSourcesList(streams: any) { else if (tab.key === 'weather') panelContent = csWeatherSources.render(weatherSourceItems); else if (tab.key === 'home_assistant') panelContent = csHASources.render(haSourceItems); else if (tab.key === 'assets') panelContent = csAssets.render(assetItems); + else if (tab.key === 'game') panelContent = csGameIntegrations.render(gameIntegrationItems); else if (tab.key === 'video') panelContent = csVideoStreams.render(videoItems); else panelContent = csStaticStreams.render(staticItems); return `
${panelContent}
`; }).join(''); container.innerHTML = panels; - CardSection.bindAll([csRawStreams, csRawTemplates, csProcStreams, csProcTemplates, csCSPTemplates, csColorStrips, csGradients, csAudioMulti, csAudioMono, csAudioBandExtract, csAudioTemplates, csStaticStreams, csVideoStreams, csValueSources, csSyncClocks, csWeatherSources, csHASources, csAssets]); + CardSection.bindAll([csRawStreams, csRawTemplates, csProcStreams, csProcTemplates, csCSPTemplates, csColorStrips, csGradients, csAudioMulti, csAudioMono, csAudioBandExtract, csAudioTemplates, csStaticStreams, csVideoStreams, csValueSources, csSyncClocks, csWeatherSources, csHASources, csAssets, csGameIntegrations]); // Event delegation for card actions (replaces inline onclick handlers) initSyncClockDelegation(container); @@ -889,6 +902,7 @@ function renderPictureSourcesList(streams: any) { 'weather-sources': 'weather', 'ha-sources': 'home_assistant', 'assets': 'assets', + 'game-integrations': 'game', }); } } diff --git a/server/src/wled_controller/static/js/features/value-sources.ts b/server/src/wled_controller/static/js/features/value-sources.ts index 353575f..4e90134 100644 --- a/server/src/wled_controller/static/js/features/value-sources.ts +++ b/server/src/wled_controller/static/js/features/value-sources.ts @@ -13,6 +13,7 @@ import { _cachedValueSources, _cachedAudioSources, _cachedStreams, apiKey, valueSourcesCache, _cachedHASources, _cachedColorStripSources, gradientsCache, GradientEntity, + _cachedGameIntegrations, _cachedGameAdapters, gameIntegrationsCache, gameAdaptersCache, } from '../core/state.ts'; import { API_BASE, fetchWithAuth, escapeHtml } from '../core/api.ts'; import { t } from '../core/i18n.ts'; @@ -23,7 +24,7 @@ import { ICON_CLONE, ICON_EDIT, ICON_TEST, ICON_LED_PREVIEW, ICON_ACTIVITY, ICON_TIMER, ICON_MOVE_VERTICAL, ICON_CLOCK, ICON_MUSIC, ICON_TRENDING_UP, ICON_MAP_PIN, ICON_MONITOR, ICON_REFRESH, ICON_TRASH, - ICON_HOME, ICON_RAINBOW, ICON_LINK, ICON_DROPLETS, + ICON_HOME, ICON_RAINBOW, ICON_LINK, ICON_DROPLETS, ICON_GAMEPAD, } from '../core/icons.ts'; import { wrapCard } from '../core/card-colors.ts'; import { TagInput, renderTagChips } from '../core/tag-input.ts'; @@ -65,6 +66,7 @@ class ValueSourceModal extends Modal { if (_vsGradientEasingIconSelect) { _vsGradientEasingIconSelect.destroy(); _vsGradientEasingIconSelect = null; } if (_vsBehaviorIconSelect) { _vsBehaviorIconSelect.destroy(); _vsBehaviorIconSelect = null; } if (_vsMetricIconSelect) { _vsMetricIconSelect.destroy(); _vsMetricIconSelect = null; } + if (_vsGameIntegrationEntitySelect) { _vsGameIntegrationEntitySelect.destroy(); _vsGameIntegrationEntitySelect = null; } } snapshotValues() { @@ -136,13 +138,16 @@ function _autoGenerateVSName() { } else if (type === 'system_metrics') { const metric = (document.getElementById('value-source-metric') as HTMLSelectElement).value; detail = t(`value_source.metric.${metric}`); + } else if (type === 'game_event') { + const eventType = (document.getElementById('value-source-game-event-type') as HTMLSelectElement)?.value; + if (eventType) detail = eventType; } (document.getElementById('value-source-name') as HTMLInputElement).value = detail ? `${typeLabel} · ${detail}` : typeLabel; } /* ── Icon-grid type selector ──────────────────────────────────── */ -const VS_FLOAT_TYPE_KEYS = ['static', 'animated', 'audio', 'adaptive_time', 'adaptive_scene', 'daylight', 'ha_entity', 'system_metrics']; +const VS_FLOAT_TYPE_KEYS = ['static', 'animated', 'audio', 'adaptive_time', 'adaptive_scene', 'daylight', 'ha_entity', 'system_metrics', 'game_event']; const VS_COLOR_TYPE_KEYS = ['static_color', 'animated_color', 'adaptive_time_color', 'gradient_map', 'css_extract']; const VS_TYPE_KEYS = [...VS_FLOAT_TYPE_KEYS, ...VS_COLOR_TYPE_KEYS]; @@ -348,6 +353,61 @@ function _onMetricChange(metric: string) { if (sensorFields) sensorFields.style.display = sensorMetrics.includes(metric) ? '' : 'none'; } +// ── Game Event Value Source helpers ── + +let _vsGameIntegrationEntitySelect: EntitySelect | null = null; + +function _populateVSGameIntegrationDropdown(selectedId: string = '') { + const sel = document.getElementById('value-source-game-integration') as HTMLSelectElement; + const integrations = _cachedGameIntegrations || []; + const prev = selectedId || sel.value; + sel.innerHTML = `` + + integrations.map(gi => ``).join(''); + sel.value = prev || ''; + + if (_vsGameIntegrationEntitySelect) _vsGameIntegrationEntitySelect.destroy(); + _vsGameIntegrationEntitySelect = new EntitySelect({ + target: sel, + getItems: () => integrations.map(gi => ({ + value: gi.id, + label: gi.name, + icon: ICON_GAMEPAD, + desc: gi.adapter_type, + })), + placeholder: t('palette.search'), + }); + + // Update event type dropdown when integration changes + sel.onchange = () => _populateVSGameEventTypeDropdown(''); +} + +function _populateVSGameEventTypeDropdown(selectedType: string = '') { + const eventTypeSel = document.getElementById('value-source-game-event-type') as HTMLSelectElement; + const giId = (document.getElementById('value-source-game-integration') as HTMLSelectElement)?.value; + + // Get continuous events from the selected integration's adapter + const CONTINUOUS_EVENTS = ['health', 'armor', 'mana', 'ammo', 'stamina', 'shield', 'score', 'gold', 'xp', 'level']; + let eventTypes = CONTINUOUS_EVENTS; + + if (giId) { + const gi = (_cachedGameIntegrations || []).find(g => g.id === giId); + if (gi) { + const adapter = (_cachedGameAdapters || []).find(a => a.adapter_type === gi.adapter_type); + if (adapter && adapter.supported_events.length > 0) { + // Filter to continuous events only + eventTypes = adapter.supported_events.filter(e => CONTINUOUS_EVENTS.includes(e)); + if (eventTypes.length === 0) eventTypes = adapter.supported_events; + } + } + } + + const prev = selectedType || eventTypeSel.value; + eventTypeSel.innerHTML = eventTypes.map(et => + `` + ).join(''); + if (prev && eventTypes.includes(prev)) eventTypeSel.value = prev; +} + function _ensureVSTypeIconSelect() { const sel = document.getElementById('value-source-type'); if (!sel) return; @@ -484,6 +544,14 @@ export async function showValueSourceModal(editData: any, presetType: any = null _setSlider('value-source-poll-interval', editData.poll_interval ?? 1.0); _setSlider('value-source-sysmetric-smoothing', editData.smoothing ?? 0); _onMetricChange(editData.metric || 'cpu_load'); + } else if (editData.source_type === 'game_event') { + _populateVSGameIntegrationDropdown(editData.game_integration_id || ''); + _populateVSGameEventTypeDropdown(editData.event_type || 'health'); + (document.getElementById('value-source-ge-min') as HTMLInputElement).value = String(editData.min_game_value ?? 0); + (document.getElementById('value-source-ge-max') as HTMLInputElement).value = String(editData.max_game_value ?? 100); + _setSlider('value-source-ge-smoothing', editData.smoothing ?? 0); + _setSlider('value-source-ge-default', editData.default_value ?? 0.5); + _setSlider('value-source-ge-timeout', editData.timeout ?? 5.0); } } else { (document.getElementById('value-source-name') as HTMLInputElement).value = ''; @@ -590,6 +658,10 @@ export function onValueSourceTypeChange() { _ensureMetricIconSelect(); _onMetricChange((document.getElementById('value-source-metric') as HTMLSelectElement).value); } + (document.getElementById('value-source-game-event-section') as HTMLElement).style.display = type === 'game_event' ? '' : 'none'; + if (type === 'game_event') { + _populateVSGameIntegrationDropdown(''); + } (document.getElementById('value-source-adaptive-range-section') as HTMLElement).style.display = (type === 'adaptive_time' || type === 'adaptive_scene' || type === 'daylight') ? '' : 'none'; @@ -754,6 +826,19 @@ export async function saveValueSource() { payload.sensor_label = (document.getElementById('value-source-sensor-label') as HTMLInputElement).value; payload.poll_interval = parseFloat((document.getElementById('value-source-poll-interval') as HTMLInputElement).value) || 1.0; payload.smoothing = parseFloat((document.getElementById('value-source-sysmetric-smoothing') as HTMLInputElement).value) || 0; + } else if (sourceType === 'game_event') { + payload.game_integration_id = (document.getElementById('value-source-game-integration') as HTMLSelectElement).value; + payload.event_type = (document.getElementById('value-source-game-event-type') as HTMLSelectElement).value; + payload.min_game_value = parseFloat((document.getElementById('value-source-ge-min') as HTMLInputElement).value) || 0; + payload.max_game_value = parseFloat((document.getElementById('value-source-ge-max') as HTMLInputElement).value) || 100; + payload.smoothing = parseFloat((document.getElementById('value-source-ge-smoothing') as HTMLInputElement).value) || 0; + payload.default_value = parseFloat((document.getElementById('value-source-ge-default') as HTMLInputElement).value) || 0.5; + payload.timeout = parseFloat((document.getElementById('value-source-ge-timeout') as HTMLInputElement).value) || 5.0; + if (!payload.game_integration_id) { + errorEl.textContent = t('value_source.game_event.integration') + ' required'; + errorEl.style.display = ''; + return; + } } try { diff --git a/server/src/wled_controller/static/js/global.d.ts b/server/src/wled_controller/static/js/global.d.ts index 0056cba..89866b5 100644 --- a/server/src/wled_controller/static/js/global.d.ts +++ b/server/src/wled_controller/static/js/global.d.ts @@ -178,7 +178,7 @@ interface Window { openAutomationEditor: (...args: any[]) => any; closeAutomationEditorModal: (...args: any[]) => any; saveAutomationEditor: (...args: any[]) => any; - addAutomationCondition: (...args: any[]) => any; + addAutomationRule: (...args: any[]) => any; toggleAutomationEnabled: (...args: any[]) => any; cloneAutomation: (...args: any[]) => any; deleteAutomation: (...args: any[]) => any; @@ -194,6 +194,21 @@ interface Window { deleteScenePreset: (...args: any[]) => any; addSceneTarget: (...args: any[]) => any; + // ─── Game Integration ─── + showGameIntegrationEditor: (...args: any[]) => any; + saveGameIntegration: (...args: any[]) => any; + closeGameIntegrationModal: (...args: any[]) => any; + cloneGameIntegration: (...args: any[]) => any; + deleteGameIntegration: (...args: any[]) => any; + addGameMapping: (...args: any[]) => any; + removeGameMapping: (...args: any[]) => any; + onMappingPresetChange: (...args: any[]) => any; + testGameConnection: (...args: any[]) => any; + showGameEventMonitor: (...args: any[]) => any; + openSetupInstructions: (...args: any[]) => any; + closeSetupInstructions: (...args: any[]) => any; + autoSetupGameIntegration: (...args: any[]) => any; + // ─── Device Discovery ─── onDeviceTypeChanged: (...args: any[]) => any; updateBaudFpsHint: (...args: any[]) => any; @@ -264,6 +279,9 @@ startTargetOverlay: (...args: any[]) => any; applyCssTestSettings: (...args: any[]) => any; fireCssTestNotification: (...args: any[]) => any; fireCssTestNotificationLayer: (...args: any[]) => any; + addCSSGameMapping: () => void; + removeCSSGameMapping: (index: number) => void; + onCSSGameMappingPresetChange: () => void; // ─── Audio Sources ─── showAudioSourceModal: (...args: any[]) => any; diff --git a/server/src/wled_controller/static/js/types.ts b/server/src/wled_controller/static/js/types.ts index bb63968..1822ad8 100644 --- a/server/src/wled_controller/static/js/types.ts +++ b/server/src/wled_controller/static/js/types.ts @@ -135,7 +135,8 @@ export type CSSSourceType = | 'picture' | 'picture_advanced' | 'static' | 'gradient' | 'color_cycle' | 'effect' | 'composite' | 'mapped' | 'audio' | 'api_input' | 'notification' | 'daylight' - | 'candlelight' | 'processed' | 'weather' | 'key_colors'; + | 'candlelight' | 'processed' | 'weather' | 'key_colors' + | 'game_event'; export interface ColorStop { position: number; @@ -282,6 +283,11 @@ export interface ColorStripSource { // Key Colors rectangles?: KeyColorRectangle[]; brightness?: BindableFloat; + + // Game Event + game_integration_id?: string; + idle_color?: BindableColor; + event_mappings?: GameEventMapping[]; } // ── Pattern Template ────────────────────────────────────────── @@ -310,7 +316,8 @@ export type ValueSourceType = | 'static' | 'animated' | 'audio' | 'adaptive_time' | 'adaptive_scene' | 'daylight' | 'static_color' | 'animated_color' | 'adaptive_time_color' - | 'ha_entity' | 'gradient_map' | 'css_extract'; + | 'ha_entity' | 'gradient_map' | 'css_extract' + | 'system_metrics' | 'game_event'; export interface SchedulePoint { time: string; @@ -449,6 +456,18 @@ export interface SystemMetricsValueSource extends ValueSourceBase { smoothing: number; } +export interface GameEventValueSource extends ValueSourceBase { + source_type: 'game_event'; + return_type: 'float'; + game_integration_id: string; + event_type: string; + min_game_value: number; + max_game_value: number; + smoothing: number; + default_value: number; + timeout: number; +} + export type ValueSource = | StaticValueSource | AnimatedValueSource @@ -462,7 +481,8 @@ export type ValueSource = | HAEntityValueSource | GradientMapValueSource | CSSExtractValueSource - | SystemMetricsValueSource; + | SystemMetricsValueSource + | GameEventValueSource; // ── Audio Source ─────────────────────────────────────────────── @@ -834,6 +854,81 @@ export interface AutomationListResponse { count: number; } +// ── Game Integration ───────────────────────────────────────── + +export interface GameEventMapping { + event_type: string; + effect_type: string; + color: number[]; + duration_ms: number; + intensity: number; + priority: number; +} + +export interface GameIntegration { + id: string; + name: string; + adapter_type: string; + adapter_config: Record; + event_mappings: GameEventMapping[]; + enabled: boolean; + description?: string; + tags: string[]; + created_at: string; + updated_at: string; +} + +export interface GameIntegrationListResponse { + integrations: GameIntegration[]; + count: number; +} + +export interface GameAdapterConfigField { + name: string; + type: string; + label?: string; + default?: any; + required?: boolean; + hint?: string; +} + +export interface GameAdapterInfo { + adapter_type: string; + display_name: string; + game_name: string; + supported_events: string[]; + config_schema: GameAdapterConfigField[]; + setup_instructions?: string; + supports_auto_setup?: boolean; +} + +export interface GameAdapterListResponse { + adapters: GameAdapterInfo[]; +} + +export interface GameEventRecord { + timestamp: string; + event_type: string; + value?: number; + data?: Record; +} + +export interface GameIntegrationStatus { + integration_id: string; + connected: boolean; + last_event_at?: string; + event_count: number; + error?: string; +} + +export interface EffectPreset { + key: string; + name: string; + description: string; + target_game_types: string[]; + event_mappings: GameEventMapping[]; +} + // ── Component Option Types (re-exported from authoritative sources) ─── export type { IconSelectItem, IconSelectOpts } from './core/icon-select.ts'; diff --git a/server/src/wled_controller/static/locales/en.json b/server/src/wled_controller/static/locales/en.json index bafc390..c0deea7 100644 --- a/server/src/wled_controller/static/locales/en.json +++ b/server/src/wled_controller/static/locales/en.json @@ -2150,5 +2150,91 @@ "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_license": "MIT License", + + "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.", + + "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." } diff --git a/server/src/wled_controller/static/locales/ru.json b/server/src/wled_controller/static/locales/ru.json index 254ddbf..bc40286 100644 --- a/server/src/wled_controller/static/locales/ru.json +++ b/server/src/wled_controller/static/locales/ru.json @@ -1915,5 +1915,91 @@ "donation.about_title": "О LedGrab", "donation.about_opensource": "LedGrab — программа с открытым исходным кодом, бесплатная для использования и модификации.", "donation.about_donate": "Поддержать разработку", - "donation.about_license": "Лицензия MIT" + "donation.about_license": "Лицензия MIT", + + "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": "Выберите игровую интеграцию.", + + "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": "Секунды тишины до возврата к значению по умолчанию." } diff --git a/server/src/wled_controller/static/locales/zh.json b/server/src/wled_controller/static/locales/zh.json index 35c7034..4ec2b73 100644 --- a/server/src/wled_controller/static/locales/zh.json +++ b/server/src/wled_controller/static/locales/zh.json @@ -1913,5 +1913,91 @@ "donation.about_title": "关于 LedGrab", "donation.about_opensource": "LedGrab 是开源软件,可免费使用和修改。", "donation.about_donate": "支持开发", - "donation.about_license": "MIT 许可证" + "donation.about_license": "MIT 许可证", + + "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": "请选择游戏集成。", + + "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": "恢复到默认值前的静默秒数。" } diff --git a/server/src/wled_controller/storage/automation.py b/server/src/wled_controller/storage/automation.py index b813b8a..92826f0 100644 --- a/server/src/wled_controller/storage/automation.py +++ b/server/src/wled_controller/storage/automation.py @@ -1,4 +1,4 @@ -"""Automation and Condition data models.""" +"""Automation and Rule data models.""" import logging from dataclasses import dataclass, field @@ -9,40 +9,30 @@ logger = logging.getLogger(__name__) @dataclass -class Condition: - """Base condition — polymorphic via condition_type discriminator.""" +class Rule: + """Base rule — polymorphic via rule_type discriminator.""" - condition_type: str + rule_type: str def to_dict(self) -> dict: - return {"condition_type": self.condition_type} + return {"rule_type": self.rule_type} @classmethod - def from_dict(cls, data: dict) -> "Condition": + def from_dict(cls, data: dict) -> "Rule": """Factory: dispatch to the correct subclass via registry.""" - ct = data.get("condition_type", "") - subcls = _CONDITION_MAP.get(ct) + # Support legacy "condition_type" key for migration + rt = data.get("rule_type") or data.get("condition_type", "") + subcls = _RULE_MAP.get(rt) if subcls is None: - raise ValueError(f"Unknown condition type: {ct}") + raise ValueError(f"Unknown rule type: {rt}") return subcls.from_dict(data) @dataclass -class AlwaysCondition(Condition): - """Always-true condition — automation activates unconditionally when enabled.""" - - condition_type: str = "always" - - @classmethod - def from_dict(cls, data: dict) -> "AlwaysCondition": - return cls() - - -@dataclass -class ApplicationCondition(Condition): +class ApplicationRule(Rule): """Activate when specified applications are running or topmost.""" - condition_type: str = "application" + rule_type: str = "application" apps: List[str] = field(default_factory=list) match_type: str = "running" # "running" | "topmost" @@ -53,7 +43,7 @@ class ApplicationCondition(Condition): return d @classmethod - def from_dict(cls, data: dict) -> "ApplicationCondition": + def from_dict(cls, data: dict) -> "ApplicationRule": return cls( apps=data.get("apps", []), match_type=data.get("match_type", "running"), @@ -61,14 +51,14 @@ class ApplicationCondition(Condition): @dataclass -class TimeOfDayCondition(Condition): +class TimeOfDayRule(Rule): """Activate during a specific time range (server local time). Supports overnight ranges: if start_time > end_time, the range wraps around midnight (e.g. 22:00 → 06:00). """ - condition_type: str = "time_of_day" + rule_type: str = "time_of_day" start_time: str = "00:00" # HH:MM end_time: str = "23:59" # HH:MM @@ -79,7 +69,7 @@ class TimeOfDayCondition(Condition): return d @classmethod - def from_dict(cls, data: dict) -> "TimeOfDayCondition": + def from_dict(cls, data: dict) -> "TimeOfDayRule": return cls( start_time=data.get("start_time", "00:00"), end_time=data.get("end_time", "23:59"), @@ -87,10 +77,10 @@ class TimeOfDayCondition(Condition): @dataclass -class SystemIdleCondition(Condition): +class SystemIdleRule(Rule): """Activate based on system idle time (keyboard/mouse inactivity).""" - condition_type: str = "system_idle" + rule_type: str = "system_idle" idle_minutes: int = 5 when_idle: bool = True # True = active when idle; False = active when NOT idle @@ -101,7 +91,7 @@ class SystemIdleCondition(Condition): return d @classmethod - def from_dict(cls, data: dict) -> "SystemIdleCondition": + def from_dict(cls, data: dict) -> "SystemIdleRule": return cls( idle_minutes=data.get("idle_minutes", 5), when_idle=data.get("when_idle", True), @@ -109,10 +99,10 @@ class SystemIdleCondition(Condition): @dataclass -class DisplayStateCondition(Condition): +class DisplayStateRule(Rule): """Activate based on display/monitor power state.""" - condition_type: str = "display_state" + rule_type: str = "display_state" state: str = "on" # "on" | "off" def to_dict(self) -> dict: @@ -121,17 +111,17 @@ class DisplayStateCondition(Condition): return d @classmethod - def from_dict(cls, data: dict) -> "DisplayStateCondition": + def from_dict(cls, data: dict) -> "DisplayStateRule": return cls( state=data.get("state", "on"), ) @dataclass -class MQTTCondition(Condition): +class MQTTRule(Rule): """Activate based on an MQTT topic value.""" - condition_type: str = "mqtt" + rule_type: str = "mqtt" topic: str = "" payload: str = "" match_mode: str = "exact" # "exact" | "contains" | "regex" @@ -144,7 +134,7 @@ class MQTTCondition(Condition): return d @classmethod - def from_dict(cls, data: dict) -> "MQTTCondition": + def from_dict(cls, data: dict) -> "MQTTRule": return cls( topic=data.get("topic", ""), payload=data.get("payload", ""), @@ -153,10 +143,10 @@ class MQTTCondition(Condition): @dataclass -class WebhookCondition(Condition): +class WebhookRule(Rule): """Activate via an HTTP webhook call with a secret token.""" - condition_type: str = "webhook" + rule_type: str = "webhook" token: str = "" # auto-generated 128-bit hex secret def to_dict(self) -> dict: @@ -165,26 +155,26 @@ class WebhookCondition(Condition): return d @classmethod - def from_dict(cls, data: dict) -> "WebhookCondition": + def from_dict(cls, data: dict) -> "WebhookRule": return cls(token=data.get("token", "")) @dataclass -class StartupCondition(Condition): +class StartupRule(Rule): """Activate when the server starts — stays active while enabled.""" - condition_type: str = "startup" + rule_type: str = "startup" @classmethod - def from_dict(cls, data: dict) -> "StartupCondition": + def from_dict(cls, data: dict) -> "StartupRule": return cls() @dataclass -class HomeAssistantCondition(Condition): +class HomeAssistantRule(Rule): """Activate based on a Home Assistant entity state.""" - condition_type: str = "home_assistant" + rule_type: str = "home_assistant" ha_source_id: str = "" # references HomeAssistantSource entity_id: str = "" # e.g. "binary_sensor.front_door" state: str = "" # expected state value @@ -199,7 +189,7 @@ class HomeAssistantCondition(Condition): return d @classmethod - def from_dict(cls, data: dict) -> "HomeAssistantCondition": + def from_dict(cls, data: dict) -> "HomeAssistantRule": return cls( ha_source_id=data.get("ha_source_id", ""), entity_id=data.get("entity_id", ""), @@ -208,42 +198,73 @@ class HomeAssistantCondition(Condition): ) -_CONDITION_MAP: Dict[str, Type[Condition]] = { - "always": AlwaysCondition, - "application": ApplicationCondition, - "time_of_day": TimeOfDayCondition, - "system_idle": SystemIdleCondition, - "display_state": DisplayStateCondition, - "mqtt": MQTTCondition, - "webhook": WebhookCondition, - "startup": StartupCondition, - "home_assistant": HomeAssistantCondition, +_RULE_MAP: Dict[str, Type[Rule]] = { + "application": ApplicationRule, + "time_of_day": TimeOfDayRule, + "system_idle": SystemIdleRule, + "display_state": DisplayStateRule, + "mqtt": MQTTRule, + "webhook": WebhookRule, + "startup": StartupRule, + "home_assistant": HomeAssistantRule, + # Legacy: "always" maps to StartupRule for migration + "always": StartupRule, } +# ── Backward-compatible aliases (for imports in other modules during transition) ── +Condition = Rule +ApplicationCondition = ApplicationRule +TimeOfDayCondition = TimeOfDayRule +SystemIdleCondition = SystemIdleRule +DisplayStateCondition = DisplayStateRule +MQTTCondition = MQTTRule +WebhookCondition = WebhookRule +StartupCondition = StartupRule +HomeAssistantCondition = HomeAssistantRule +AlwaysCondition = StartupRule # "Always" removed — maps to Startup + + @dataclass class Automation: - """Automation that activates a scene preset based on conditions.""" + """Automation that activates a scene preset based on rules.""" id: str name: str enabled: bool - condition_logic: str # "or" | "and" - conditions: List[Condition] - scene_preset_id: Optional[str] # scene to activate when conditions are met + rule_logic: str # "or" | "and" + rules: List[Rule] + scene_preset_id: Optional[str] # scene to activate when rules are met deactivation_mode: str # "none" | "revert" | "fallback_scene" deactivation_scene_preset_id: Optional[str] # scene for fallback_scene mode created_at: datetime updated_at: datetime tags: List[str] = field(default_factory=list) + # Backward-compatible property aliases + @property + def condition_logic(self) -> str: + return self.rule_logic + + @condition_logic.setter + def condition_logic(self, value: str) -> None: + self.rule_logic = value + + @property + def conditions(self) -> List[Rule]: + return self.rules + + @conditions.setter + def conditions(self, value: List[Rule]) -> None: + self.rules = value + def to_dict(self) -> dict: return { "id": self.id, "name": self.name, "enabled": self.enabled, - "condition_logic": self.condition_logic, - "conditions": [c.to_dict() for c in self.conditions], + "rule_logic": self.rule_logic, + "rules": [r.to_dict() for r in self.rules], "scene_preset_id": self.scene_preset_id, "deactivation_mode": self.deactivation_mode, "deactivation_scene_preset_id": self.deactivation_scene_preset_id, @@ -254,20 +275,26 @@ class Automation: @classmethod def from_dict(cls, data: dict) -> "Automation": - conditions = [] - for c_data in data.get("conditions", []): + rules = [] + # Support legacy "conditions" key for migration + raw_rules = data.get("rules") or data.get("conditions", []) + for r_data in raw_rules: try: - conditions.append(Condition.from_dict(c_data)) + rule = Rule.from_dict(r_data) + # Skip "always" rules during migration (they're redundant) + if r_data.get("rule_type") == "always" or r_data.get("condition_type") == "always": + logger.info("Migrating 'always' condition to startup rule") + rule = StartupRule() + rules.append(rule) except ValueError as e: - logger.warning("Skipping unknown condition type on load: %s", e) - pass # skip unknown condition types on load + logger.warning("Skipping unknown rule type on load: %s", e) return cls( id=data["id"], name=data["name"], enabled=data.get("enabled", True), - condition_logic=data.get("condition_logic", "or"), - conditions=conditions, + rule_logic=data.get("rule_logic") or data.get("condition_logic", "or"), + rules=rules, scene_preset_id=data.get("scene_preset_id"), deactivation_mode=data.get("deactivation_mode", "none"), deactivation_scene_preset_id=data.get("deactivation_scene_preset_id"), diff --git a/server/src/wled_controller/storage/automation_store.py b/server/src/wled_controller/storage/automation_store.py index ec54791..ff58e74 100644 --- a/server/src/wled_controller/storage/automation_store.py +++ b/server/src/wled_controller/storage/automation_store.py @@ -4,7 +4,7 @@ import uuid from datetime import datetime, timezone from typing import List, Optional -from wled_controller.storage.automation import Automation, Condition +from wled_controller.storage.automation import Automation, Rule from wled_controller.storage.base_sqlite_store import BaseSqliteStore from wled_controller.storage.database import Database from wled_controller.utils import get_logger @@ -28,13 +28,22 @@ class AutomationStore(BaseSqliteStore[Automation]): self, name: str, enabled: bool = True, - condition_logic: str = "or", - conditions: Optional[List[Condition]] = None, + rule_logic: str = "or", + rules: Optional[List[Rule]] = None, scene_preset_id: Optional[str] = None, deactivation_mode: str = "none", deactivation_scene_preset_id: Optional[str] = None, tags: Optional[List[str]] = None, + # Legacy parameter aliases + condition_logic: Optional[str] = None, + conditions: Optional[List[Rule]] = None, ) -> Automation: + # Support legacy parameter names + if condition_logic is not None and rule_logic == "or": + rule_logic = condition_logic + if conditions is not None and rules is None: + rules = conditions + for a in self._items.values(): if a.name == name: raise ValueError(f"Automation with name '{name}' already exists") @@ -46,8 +55,8 @@ class AutomationStore(BaseSqliteStore[Automation]): id=automation_id, name=name, enabled=enabled, - condition_logic=condition_logic, - conditions=conditions or [], + rule_logic=rule_logic, + rules=rules or [], scene_preset_id=scene_preset_id, deactivation_mode=deactivation_mode, deactivation_scene_preset_id=deactivation_scene_preset_id, @@ -66,13 +75,22 @@ class AutomationStore(BaseSqliteStore[Automation]): automation_id: str, name: Optional[str] = None, enabled: Optional[bool] = None, - condition_logic: Optional[str] = None, - conditions: Optional[List[Condition]] = None, + rule_logic: Optional[str] = None, + rules: Optional[List[Rule]] = None, scene_preset_id: str = "__unset__", deactivation_mode: Optional[str] = None, deactivation_scene_preset_id: str = "__unset__", tags: Optional[List[str]] = None, + # Legacy parameter aliases + condition_logic: Optional[str] = None, + conditions: Optional[List[Rule]] = None, ) -> Automation: + # Support legacy parameter names + if condition_logic is not None and rule_logic is None: + rule_logic = condition_logic + if conditions is not None and rules is None: + rules = conditions + automation = self.get(automation_id) if name is not None: @@ -80,16 +98,18 @@ class AutomationStore(BaseSqliteStore[Automation]): automation.name = name if enabled is not None: automation.enabled = enabled - if condition_logic is not None: - automation.condition_logic = condition_logic - if conditions is not None: - automation.conditions = conditions + if rule_logic is not None: + automation.rule_logic = rule_logic + if rules is not None: + automation.rules = rules if scene_preset_id != "__unset__": automation.scene_preset_id = None if scene_preset_id == "" else scene_preset_id if deactivation_mode is not None: automation.deactivation_mode = deactivation_mode if deactivation_scene_preset_id != "__unset__": - automation.deactivation_scene_preset_id = None if deactivation_scene_preset_id == "" else deactivation_scene_preset_id + automation.deactivation_scene_preset_id = ( + None if deactivation_scene_preset_id == "" else deactivation_scene_preset_id + ) if tags is not None: automation.tags = tags diff --git a/server/src/wled_controller/storage/color_strip_source.py b/server/src/wled_controller/storage/color_strip_source.py index 475c4b4..a7ec168 100644 --- a/server/src/wled_controller/storage/color_strip_source.py +++ b/server/src/wled_controller/storage/color_strip_source.py @@ -1653,6 +1653,96 @@ class KeyColorsColorStripSource(ColorStripSource): ) +@dataclass +class GameEventColorStripSource(ColorStripSource): + """Color strip source that renders LED effects in response to game events. + + Subscribes to a GameEventBus via a game integration and renders visual + effects (flash, pulse, sweep, color_shift, breathing) when matching events + arrive. When idle, outputs the configured idle_color. + + LED count auto-sizes from the connected device when led_count == 0. + """ + + game_integration_id: str = "" + idle_color: BindableColor = field(default_factory=lambda: BindableColor([0, 0, 0])) + event_mappings: List[dict] = field(default_factory=list) + led_count: int = 0 + + @property + def sharable(self) -> bool: + return False + + def to_dict(self) -> dict: + d = super().to_dict() + d["game_integration_id"] = self.game_integration_id + d["idle_color"] = self.idle_color.to_dict() + d["event_mappings"] = [dict(m) for m in self.event_mappings] + d["led_count"] = self.led_count + return d + + @classmethod + def from_dict(cls, data: dict) -> "GameEventColorStripSource": + common = _parse_css_common(data) + raw_mappings = data.get("event_mappings") + return cls( + **common, + source_type="game_event", + game_integration_id=data.get("game_integration_id") or "", + idle_color=BindableColor.from_raw( + data.get("idle_color"), + default=[0, 0, 0], + ), + event_mappings=raw_mappings if isinstance(raw_mappings, list) else [], + led_count=data.get("led_count") or 0, + ) + + @classmethod + def create_from_kwargs( + cls, + *, + id: str, + name: str, + source_type: str, + created_at: datetime, + updated_at: datetime, + description=None, + clock_id=None, + tags=None, + game_integration_id=None, + idle_color=None, + event_mappings=None, + led_count=None, + **_kwargs, + ): + return cls( + id=id, + name=name, + source_type="game_event", + created_at=created_at, + updated_at=updated_at, + description=description, + clock_id=clock_id, + tags=tags or [], + game_integration_id=game_integration_id or "", + idle_color=BindableColor.from_raw(idle_color, default=[0, 0, 0]), + event_mappings=event_mappings if isinstance(event_mappings, list) else [], + led_count=led_count or 0, + ) + + def apply_update(self, **kwargs) -> None: + if kwargs.get("game_integration_id") is not None: + self.game_integration_id = kwargs["game_integration_id"] + if kwargs.get("idle_color") is not None: + self.idle_color = self.idle_color.apply_update(kwargs["idle_color"]) + if kwargs.get("event_mappings") is not None: + raw = kwargs["event_mappings"] + if isinstance(raw, list): + self.event_mappings = raw + if kwargs.get("led_count") is not None: + self.led_count = kwargs["led_count"] + + # -- Source type registry -- # Maps source_type string to its subclass for factory dispatch. _SOURCE_TYPE_MAP: Dict[str, Type[ColorStripSource]] = { @@ -1672,4 +1762,5 @@ _SOURCE_TYPE_MAP: Dict[str, Type[ColorStripSource]] = { "processed": ProcessedColorStripSource, "weather": WeatherColorStripSource, "key_colors": KeyColorsColorStripSource, + "game_event": GameEventColorStripSource, } diff --git a/server/src/wled_controller/storage/database.py b/server/src/wled_controller/storage/database.py index 5b213ed..a7dc8b5 100644 --- a/server/src/wled_controller/storage/database.py +++ b/server/src/wled_controller/storage/database.py @@ -56,6 +56,7 @@ _ENTITY_TABLES = [ "weather_sources", "assets", "home_assistant_sources", + "game_integrations", ] diff --git a/server/src/wled_controller/storage/game_integration.py b/server/src/wled_controller/storage/game_integration.py new file mode 100644 index 0000000..bbb7fcf --- /dev/null +++ b/server/src/wled_controller/storage/game_integration.py @@ -0,0 +1,169 @@ +"""Game integration configuration data models. + +Defines the GameIntegrationConfig dataclass and EventMapping dataclass +for persisting game integration settings (adapter type, event mappings, +per-integration config). +""" + +import uuid +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import Any, List, Optional + + +@dataclass +class EventMapping: + """Maps a standard game event type to a visual effect. + + Attributes: + event_type: Standard event type from the vocabulary (e.g. "health", "kill"). + effect: Effect name to trigger (e.g. "flash", "pulse", "gradient"). + color: RGB color as [R, G, B] (0-255 each). + duration_ms: Effect duration in milliseconds. + intensity: Effect intensity 0.0-1.0. + priority: Priority for effect stacking (higher wins). + """ + + event_type: str + effect: str = "flash" + color: List[int] = field(default_factory=lambda: [255, 0, 0]) + duration_ms: int = 500 + intensity: float = 1.0 + priority: int = 0 + + def to_dict(self) -> dict: + return { + "event_type": self.event_type, + "effect": self.effect, + "color": list(self.color), + "duration_ms": self.duration_ms, + "intensity": self.intensity, + "priority": self.priority, + } + + @classmethod + def from_dict(cls, data: dict) -> "EventMapping": + return cls( + event_type=data["event_type"], + effect=data.get("effect", "flash"), + color=list(data.get("color", [255, 0, 0])), + duration_ms=data.get("duration_ms", 500), + intensity=data.get("intensity", 1.0), + priority=data.get("priority", 0), + ) + + +@dataclass +class GameIntegrationConfig: + """Persistent configuration for a game integration. + + Attributes: + id: Unique identifier (gi_<8hex>). + name: Human-readable name. + adapter_type: Registered adapter type string. + enabled: Whether this integration is active. + adapter_config: Adapter-specific settings (secrets, mappings, etc.). + event_mappings: List of event-to-effect mappings. + created_at: Creation timestamp. + updated_at: Last modification timestamp. + description: Optional description. + tags: User-defined tags. + """ + + id: str + name: str + adapter_type: str + enabled: bool + adapter_config: dict[str, Any] + event_mappings: List[EventMapping] + created_at: datetime + updated_at: datetime + description: Optional[str] = None + tags: List[str] = field(default_factory=list) + + def to_dict(self) -> dict: + return { + "id": self.id, + "name": self.name, + "adapter_type": self.adapter_type, + "enabled": self.enabled, + "adapter_config": dict(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(), + "description": self.description, + "tags": list(self.tags), + } + + @classmethod + def from_dict(cls, data: dict) -> "GameIntegrationConfig": + mappings = [EventMapping.from_dict(m) for m in data.get("event_mappings", [])] + return cls( + id=data["id"], + name=data["name"], + adapter_type=data["adapter_type"], + enabled=data.get("enabled", True), + adapter_config=data.get("adapter_config", {}), + event_mappings=mappings, + created_at=( + datetime.fromisoformat(data["created_at"]) + if isinstance(data.get("created_at"), str) + else data.get("created_at", datetime.now(timezone.utc)) + ), + updated_at=( + datetime.fromisoformat(data["updated_at"]) + if isinstance(data.get("updated_at"), str) + else data.get("updated_at", datetime.now(timezone.utc)) + ), + description=data.get("description"), + tags=data.get("tags", []), + ) + + @staticmethod + def create_from_kwargs( + name: str, + adapter_type: str, + enabled: bool = True, + adapter_config: Optional[dict[str, Any]] = None, + event_mappings: Optional[List[EventMapping]] = None, + description: Optional[str] = None, + tags: Optional[List[str]] = None, + ) -> "GameIntegrationConfig": + """Factory method to create a new config with generated ID and timestamps.""" + now = datetime.now(timezone.utc) + return GameIntegrationConfig( + id=f"gi_{uuid.uuid4().hex[:8]}", + name=name, + adapter_type=adapter_type, + enabled=enabled, + adapter_config=adapter_config or {}, + event_mappings=event_mappings or [], + created_at=now, + updated_at=now, + description=description, + tags=tags or [], + ) + + def apply_update( + self, + name: Optional[str] = None, + adapter_type: Optional[str] = None, + enabled: Optional[bool] = None, + adapter_config: Optional[dict[str, Any]] = None, + event_mappings: Optional[List[EventMapping]] = None, + description: Optional[str] = None, + tags: Optional[List[str]] = None, + ) -> "GameIntegrationConfig": + """Return a new config with updated fields (immutable update).""" + return GameIntegrationConfig( + id=self.id, + name=name if name is not None else self.name, + adapter_type=adapter_type if adapter_type is not None else self.adapter_type, + enabled=enabled if enabled is not None else self.enabled, + adapter_config=adapter_config if adapter_config is not None else self.adapter_config, + event_mappings=event_mappings if event_mappings is not None else self.event_mappings, + created_at=self.created_at, + updated_at=datetime.now(timezone.utc), + description=description if description is not None else self.description, + tags=tags if tags is not None else self.tags, + ) diff --git a/server/src/wled_controller/storage/game_integration_store.py b/server/src/wled_controller/storage/game_integration_store.py new file mode 100644 index 0000000..8f0cd3f --- /dev/null +++ b/server/src/wled_controller/storage/game_integration_store.py @@ -0,0 +1,139 @@ +"""Game integration configuration storage using SQLite. + +Provides CRUD operations for GameIntegrationConfig entities with +name uniqueness validation and write-through caching. +""" + +from typing import Any, List, Optional + +from wled_controller.storage.base_sqlite_store import BaseSqliteStore +from wled_controller.storage.database import Database +from wled_controller.storage.game_integration import EventMapping, GameIntegrationConfig +from wled_controller.utils import get_logger + +logger = get_logger(__name__) + + +class GameIntegrationStore(BaseSqliteStore[GameIntegrationConfig]): + """Storage for game integration configurations. + + All configs are persisted to the database with write-through caching. + """ + + _table_name = "game_integrations" + _entity_name = "Game integration" + _version = "1.0.0" + + def __init__(self, db: Database) -> None: + super().__init__(db, GameIntegrationConfig.from_dict) + + # Backward-compatible aliases + get_all_integrations = BaseSqliteStore.get_all + get_integration = BaseSqliteStore.get + delete_integration = BaseSqliteStore.delete + + def create_integration( + self, + name: str, + adapter_type: str, + enabled: bool = True, + adapter_config: Optional[dict[str, Any]] = None, + event_mappings: Optional[List[EventMapping]] = None, + description: Optional[str] = None, + tags: Optional[List[str]] = None, + ) -> GameIntegrationConfig: + """Create a new game integration config. + + Args: + name: Human-readable name (must be unique). + adapter_type: Registered adapter type string. + enabled: Whether integration is active. + adapter_config: Adapter-specific settings. + event_mappings: Event-to-effect mappings. + description: Optional description. + tags: User-defined tags. + + Returns: + The newly created config. + + Raises: + ValueError: If name is empty or already taken. + """ + with self._lock: + self._check_name_unique(name) + + config = GameIntegrationConfig.create_from_kwargs( + name=name, + adapter_type=adapter_type, + enabled=enabled, + adapter_config=adapter_config, + event_mappings=event_mappings, + description=description, + tags=tags, + ) + + self._items[config.id] = config + self._save_item(config.id, config) + + logger.info(f"Created game integration: {name} ({config.id})") + return config + + def update_integration( + self, + integration_id: str, + name: Optional[str] = None, + adapter_type: Optional[str] = None, + enabled: Optional[bool] = None, + adapter_config: Optional[dict[str, Any]] = None, + event_mappings: Optional[List[EventMapping]] = None, + description: Optional[str] = None, + tags: Optional[List[str]] = None, + ) -> GameIntegrationConfig: + """Update an existing game integration config. + + Args: + integration_id: Config ID to update. + name: New name (must be unique if changed). + adapter_type: New adapter type. + enabled: New enabled state. + adapter_config: New adapter-specific settings. + event_mappings: New event-to-effect mappings. + description: New description. + tags: New tags. + + Returns: + The updated config. + + Raises: + EntityNotFoundError: If integration_id not found. + ValueError: If new name conflicts with an existing config. + """ + with self._lock: + existing = self.get(integration_id) + + if name is not None: + self._check_name_unique(name, exclude_id=integration_id) + + updated = existing.apply_update( + name=name, + adapter_type=adapter_type, + enabled=enabled, + adapter_config=adapter_config, + event_mappings=event_mappings, + description=description, + tags=tags, + ) + + self._items[integration_id] = updated + self._save_item(integration_id, updated) + + logger.info(f"Updated game integration: {integration_id}") + return updated + + def get_references(self, integration_id: str) -> List[str]: + """Return names of entities that reference this integration. + + Currently game integrations are not referenced by other entities, + but this method is provided for future cascade prevention. + """ + return [] diff --git a/server/src/wled_controller/storage/value_source.py b/server/src/wled_controller/storage/value_source.py index 2e743c7..8371eb0 100644 --- a/server/src/wled_controller/storage/value_source.py +++ b/server/src/wled_controller/storage/value_source.py @@ -481,6 +481,55 @@ VALID_SYSTEM_METRICS = ( ) +@dataclass +class GameEventValueSource(ValueSource): + """Value source driven by game events via the GameEventBus. + + Exposes game metrics (health, ammo, mana, etc.) as 0.0-1.0 scalar values. + Incoming raw game values are normalized using min/max mapping, with optional + EMA smoothing for smooth transitions. Reverts to default_value when no + events are received within the timeout period. + """ + + game_integration_id: str = "" # references a GameIntegration config + event_type: str = "health" # standard event vocabulary type + min_game_value: float = 0.0 # raw game value mapped to 0.0 + max_game_value: float = 100.0 # raw game value mapped to 1.0 + smoothing: float = 0.0 # EMA smoothing factor (0.0-1.0) + default_value: float = 0.5 # value when timed out or no events + timeout: float = 5.0 # seconds before reverting to default + + def to_dict(self) -> dict: + d = super().to_dict() + d["game_integration_id"] = self.game_integration_id + d["event_type"] = self.event_type + d["min_game_value"] = self.min_game_value + d["max_game_value"] = self.max_game_value + d["smoothing"] = self.smoothing + d["default_value"] = self.default_value + d["timeout"] = self.timeout + return d + + @classmethod + def from_dict(cls, data: dict) -> "GameEventValueSource": + common = _parse_common_fields(data) + return cls( + **common, + source_type="game_event", + game_integration_id=data.get("game_integration_id") or "", + event_type=data.get("event_type") or "health", + min_game_value=float(data.get("min_game_value") or 0.0), + max_game_value=float( + data.get("max_game_value") if data.get("max_game_value") is not None else 100.0 + ), + smoothing=float(data.get("smoothing") or 0.0), + default_value=float( + data.get("default_value") if data.get("default_value") is not None else 0.5 + ), + timeout=float(data.get("timeout") if data.get("timeout") is not None else 5.0), + ) + + @dataclass class SystemMetricsValueSource(ValueSource): """Value source that reads system hardware metrics. @@ -545,4 +594,5 @@ _VALUE_SOURCE_MAP: Dict[str, Type[ValueSource]] = { "gradient_map": GradientMapValueSource, "css_extract": CSSExtractValueSource, "system_metrics": SystemMetricsValueSource, + "game_event": GameEventValueSource, } diff --git a/server/src/wled_controller/templates/index.html b/server/src/wled_controller/templates/index.html index ed9be66..3443302 100644 --- a/server/src/wled_controller/templates/index.html +++ b/server/src/wled_controller/templates/index.html @@ -216,6 +216,7 @@ {% include 'modals/ha-light-editor.html' %} {% include 'modals/asset-upload.html' %} {% include 'modals/asset-editor.html' %} + {% include 'modals/game-integration-editor.html' %} {% include 'modals/settings.html' %} {% include 'partials/tutorial-overlay.html' %} diff --git a/server/src/wled_controller/templates/modals/automation-editor.html b/server/src/wled_controller/templates/modals/automation-editor.html index 231166e..74b99b3 100644 --- a/server/src/wled_controller/templates/modals/automation-editor.html +++ b/server/src/wled_controller/templates/modals/automation-editor.html @@ -24,7 +24,7 @@
- +
- +
@@ -69,7 +69,7 @@
- +
@@ -689,6 +690,46 @@
+ + +
diff --git a/server/src/wled_controller/templates/modals/game-integration-editor.html b/server/src/wled_controller/templates/modals/game-integration-editor.html new file mode 100644 index 0000000..5e99f5d --- /dev/null +++ b/server/src/wled_controller/templates/modals/game-integration-editor.html @@ -0,0 +1,112 @@ + + + + diff --git a/server/src/wled_controller/templates/modals/value-source-editor.html b/server/src/wled_controller/templates/modals/value-source-editor.html index b780a4b..bd7e22b 100644 --- a/server/src/wled_controller/templates/modals/value-source-editor.html +++ b/server/src/wled_controller/templates/modals/value-source-editor.html @@ -535,6 +535,78 @@
+ + +