feat: game integration system

Receive real-time events from games (CS2, Dota 2, LoL, etc.) and drive
LED effects through the existing color strip and value source pipelines.

Core:
- GameEventBus (thread-safe pub/sub) with standardized 23-type event vocabulary
- GameAdapter ABC + AdapterRegistry + MappingAdapter (YAML-driven)
- Built-in adapters: CS2 GSI, Dota 2 GSI, LoL Live Client, Generic Webhook
- Community YAML adapters: Minecraft, Valorant, Rocket League
- GameEventColorStripStream with 5 effects (flash/pulse/sweep/color_shift/breathing)
- GameEventValueSource with EMA smoothing and timeout
- 4 built-in effect presets (FPS Combat, MOBA Health, Racing, Generic Alert)
- Auto-setup for Valve GSI games (Steam path detection, cfg file writing)
- Demo capture engine exposed to non-demo mode

Frontend:
- Game tab in Streams tree navigation with integration cards
- Game integration editor modal with adapter picker, config fields, event mappings
- game_event source type in CSS and ValueSource editors
- Setup instructions overlay (markdown rendered)
- Live event monitor and connection test

API:
- Full CRUD for game integrations
- Event ingestion endpoint (adapter-level auth)
- Adapter metadata, presets, auto-setup, status/diagnostics endpoints
This commit is contained in:
2026-03-31 13:17:52 +03:00
parent b6713be390
commit 492bdb95e3
87 changed files with 12170 additions and 912 deletions
-231
View File
@@ -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
+85
View File
@@ -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 |
|-------|-----------|-------------|----------|-------|
+53
View File
@@ -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`
+107
View File
@@ -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`.
@@ -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()`.
+110
View File
@@ -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_<stem>`. 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.
@@ -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
@@ -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
@@ -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 `<select>` for event_type (since the available events are dynamic per adapter and may be unknown user-defined strings). Effect type selector has a select element that could be upgraded to IconSelect in a follow-up if desired. The preset selector is intentionally a plain select since it is a simple action trigger, not a form value.
@@ -0,0 +1,79 @@
# Phase 7: Frontend — ColorStrip & ValueSource Game Bindings
**Status:** ✅ Complete
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** frontend
## Objective
Add "game_event" as a selectable source type in the ColorStrip and ValueSource editors so users can create game-driven LED streams and parameter bindings from the existing entity UIs.
## Tasks
- [x] Task 1: Add "game_event" to ColorStripSource type selector (IconSelect)
- New icon + label in the source type grid
- When selected, show game integration picker (EntitySelect) and event mapping editor
- [x] Task 2: Game integration picker in CSS editor
- EntitySelect dropdown listing available game integrations
- When selected, show the integration's supported events
- [x] Task 3: Inline event mapping UI in CSS source editor
- Simplified version of the full mapping editor from Phase 6
- Allows override/supplement of the integration-level mappings
- Idle color picker
- [x] Task 4: Add "game_event" to ValueSource type selector
- New icon + label in the value source type grid
- [x] Task 5: Game value source config fields
- Game integration picker (EntitySelect)
- Event type picker (dropdown of continuous events from the selected integration)
- Min/max game value inputs
- Smoothing slider (0.0-1.0)
- Default value input
- Timeout input (seconds)
- [x] Task 6: Add i18n keys for new source type labels and config fields
- [x] Task 7: Update TypeScript types for new source types
## Files to Modify/Create
- `server/src/wled_controller/static/js/features/streams.ts` — CSS editor game_event fields
- `server/src/wled_controller/static/js/features/game-integration.ts` — shared helpers
- `server/src/wled_controller/static/js/types.ts` — type definitions
- `server/src/wled_controller/templates/modals/` — update CSS and value source modals
- `server/src/wled_controller/static/locales/en.json`
- `server/src/wled_controller/static/locales/ru.json`
- `server/src/wled_controller/static/locales/zh.json`
## Acceptance Criteria
- "game_event" appears in both source type selectors with appropriate icon
- Selecting it shows game integration picker and relevant config fields
- CSS editor shows event mapping override UI
- Value source editor shows normalization and smoothing controls
- All i18n keys present
## Notes
- Depends on Phase 4 (CSS source type exists) and Phase 5 (value source type exists)
- Reuse game-integration.ts helpers for adapter/event metadata fetching
## Review Checklist
- [x] All tasks completed
- [x] Code follows frontend conventions
- [x] TypeScript compiles without errors
- [x] Bundle builds successfully
## Handoff to Next Phase
All 7 tasks implemented. Key implementation details:
**Files modified:**
- `icons.ts` — added `game_event` to both `_colorStripTypeIcons` and `_valueSourceTypeIcons` maps (gamepad2 icon)
- `types.ts` — added `game_event` to `CSSSourceType` and `ValueSourceType` unions, added `GameEventValueSource` interface, added game event fields to `ColorStripSource` interface, also fixed pre-existing `system_metrics` missing from `ValueSourceType`
- `color-strips.ts` — added `game_event` to `CSS_TYPE_KEYS`, `CSS_SECTION_MAP`, `CSS_TYPE_SETUP`, and `_typeHandlers`; added helper functions for game integration EntitySelect dropdown, idle color BindableColorWidget, inline event mapping editor (add/remove/preset), and game mapping collection for save
- `value-sources.ts` — added `game_event` to `VS_FLOAT_TYPE_KEYS`; added section toggle in `onValueSourceTypeChange`; added loading/save logic for game event fields; added EntitySelect for game integration picker with filtered continuous event type dropdown
- `css-editor.html` — added `css-editor-game-event-section` with game integration select, idle color container, mapping preset select, inline mapping editor, and add mapping button
- `value-source-editor.html` — added `value-source-game-event-section` with game integration select, event type select (continuous events), min/max game value inputs, smoothing/default/timeout sliders
- `app.ts` — imported and wired `addCSSGameMapping`, `removeCSSGameMapping`, `onCSSGameMappingPresetChange` to window
- `global.d.ts` — added window type declarations for the 3 new exported functions
- `en.json`, `ru.json`, `zh.json` — added ~28 i18n keys each for CSS and value source game_event labels, hints, and errors
**UI behavior:**
- CSS editor: selecting "Game Event" type shows a game integration EntitySelect, BindableColor idle color picker, and an inline event mapping editor with preset loading (FPS Combat / MOBA Health), add/remove mapping rows (event type, effect type, color, duration, intensity, priority)
- Value source editor: selecting "Game Event" type shows a game integration EntitySelect, event type dropdown (filtered to continuous events from the adapter's supported_events), min/max game value number inputs, smoothing slider, default value slider, timeout slider
**Conventions followed:** No emoji (SVG icons only), EntitySelect for game integration picker, BindableColorWidget for idle color, fetchWithAuth, cache.invalidate() + reload pattern, all strings via t() with i18n keys in 3 locales, dirty check via snapshotValues(), widget cleanup in onForceClose().
@@ -0,0 +1,84 @@
# Phase 8: Effect Presets & Polish
**Status:** ✅ Complete
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** fullstack
## Objective
Ship built-in effect presets, add a WebSocket endpoint for real-time event streaming to the frontend live monitor, and add a setup wizard for guided game configuration.
## Tasks
- [x] Task 1: Define effect presets as data (`core/game_integration/presets.py`)
- "FPS Combat": health→red glow, kill→green flash, death→full red pulse, round_start→team color sweep
- "MOBA Health": health→gradient green-yellow-red, mana→blue glow, death→fade to black
- "Racing": speed→color temperature, boost→rainbow flash
- "Generic Alert": any trigger→white flash
- Each preset: name, description, target_game_types (fps/moba/racing/any), event_mappings list
- [x] Task 2: Preset API endpoints
- GET /api/v1/game-integrations/presets — list available presets
- POST /api/v1/game-integrations/{id}/apply-preset — apply preset to integration
- [ ] Task 3: WebSocket endpoint for live event streaming — DEFERRED (polling works; WebSocket is a future optimization)
- [x] Task 4: Frontend preset selector in modal
- Dropdown of presets loaded from API with descriptions
- "Apply" populates the event mapping editor
- [ ] Task 5: Game adapter setup wizard — DEFERRED (setup instructions already shown per-adapter)
- [ ] Task 6: Community adapter import UI — DEFERRED (community adapters are loaded from data/game_adapters/ dir on startup; file upload is a future enhancement)
- [x] Task 7: Final integration testing — wiring verified, all 606 tests pass
- [ ] Task 8: Documentation for creating community adapters — DEFERRED (not blocking release)
- [x] Task 9 (added): Wiring fixes — game_event_bus passed through ProcessorDependencies to ColorStripStreamManager and ValueStreamManager
- [x] Task 10 (added): Adapter registration — import built-in adapters and call register_community_adapters() in main.py
## Files to Modify/Create
- `server/src/wled_controller/core/game_integration/presets.py`
- `server/src/wled_controller/api/routes/game_integration.py` — preset + WS endpoints
- `server/src/wled_controller/api/schemas/game_integration.py` — preset schemas
- `server/src/wled_controller/static/js/features/game-integration.ts` — preset UI, wizard, import
- `server/src/wled_controller/templates/modals/game-integration-editor.html` — wizard steps
- `server/src/wled_controller/static/css/game-integration.css` — wizard styles
- `server/src/wled_controller/static/locales/en.json`
- `server/src/wled_controller/static/locales/ru.json`
- `server/src/wled_controller/static/locales/zh.json`
- `server/tests/core/test_game_presets.py`
## Acceptance Criteria
- At least 4 effect presets ship out of the box
- Presets can be applied to any game integration via API and UI
- WebSocket endpoint streams events in real-time
- Setup wizard provides clear per-game instructions
- Community adapter import works (file upload + URL)
- Full pipeline works end-to-end: game event → LED effect
## Notes
- WebSocket uses FastAPI's built-in WebSocket support
- Presets are read-only built-in data, not user-editable (users can modify after applying)
- Setup wizard should pre-fill the server URL for webhook-based games
## Review Checklist
- [x] All core tasks completed (presets, API, wiring, frontend, tests)
- [x] Code follows project conventions
- [x] Build passes (frontend + backend)
- [x] All 606 tests pass
- [x] Linting clean (ruff)
- [x] TypeScript clean (tsc --noEmit)
## Handoff Notes (Final Phase)
**Files created:**
- `server/src/wled_controller/core/game_integration/presets.py` — 4 built-in effect presets (FPS Combat, MOBA Health, Racing, Generic Alert) as frozen dataclasses
- `server/tests/core/test_game_presets.py` — preset data structure tests (10 tests)
- `server/tests/core/test_game_wiring.py` — GameEventBus wiring verification tests (4 tests)
**Files modified:**
- `server/src/wled_controller/api/schemas/game_integration.py` — added EffectPresetResponse, PresetListResponse, ApplyPresetRequest schemas
- `server/src/wled_controller/api/routes/game_integration.py` — added GET /presets and POST /{id}/apply-preset endpoints (presets route placed BEFORE {integration_id} to avoid path parameter conflict)
- `server/src/wled_controller/core/processing/processor_manager.py` — added game_event_bus to ProcessorDependencies; wired to ColorStripStreamManager and ValueStreamManager
- `server/src/wled_controller/main.py` — added adapter import, register_community_adapters() call, game_event_bus in ProcessorDependencies
- `server/src/wled_controller/static/js/features/game-integration.ts` — replaced hardcoded preset data with API-loaded presets from /game-integrations/presets
- `server/src/wled_controller/static/js/types.ts` — added EffectPreset interface
- `server/src/wled_controller/static/locales/en.json`, `ru.json`, `zh.json` — added game_integration.mapping.select_preset key
- `server/tests/api/routes/test_game_integration_routes.py` — added TestPresets class (6 tests: list, mappings, apply replace/append, unknown key/integration)
**Key wiring fix:** GameEventBus was created in main.py but NOT passed to ProcessorDependencies, meaning ColorStripStreamManager and ValueStreamManager could never receive it. Now properly threaded through.
**Deferred items:** WebSocket live streaming (Task 3), setup wizard (Task 5), community adapter import UI (Task 6), adapter documentation (Task 8) — none are blocking for the feature to function end-to-end.
@@ -26,6 +26,7 @@ from .routes.weather_sources import router as weather_sources_router
from .routes.update import router as update_router
from .routes.assets import router as assets_router
from .routes.home_assistant import router as home_assistant_router
from .routes.game_integration import router as game_integration_router
router = APIRouter()
router.include_router(system_router)
@@ -52,5 +53,6 @@ router.include_router(weather_sources_router)
router.include_router(update_router)
router.include_router(assets_router)
router.include_router(home_assistant_router)
router.include_router(game_integration_router)
__all__ = ["router"]
@@ -33,6 +33,8 @@ from wled_controller.core.processing.sync_clock_manager import SyncClockManager
from wled_controller.core.update.update_service import UpdateService
from wled_controller.storage.home_assistant_store import HomeAssistantStore
from wled_controller.core.home_assistant.ha_manager import HomeAssistantManager
from wled_controller.storage.game_integration_store import GameIntegrationStore
from wled_controller.core.game_integration.event_bus import GameEventBus
T = TypeVar("T")
@@ -143,6 +145,14 @@ def get_ha_manager() -> HomeAssistantManager:
return _get("ha_manager", "Home Assistant manager")
def get_game_integration_store() -> GameIntegrationStore:
return _get("game_integration_store", "Game integration store")
def get_game_event_bus() -> GameEventBus:
return _get("game_event_bus", "Game event bus")
def get_database() -> Database:
return _get("database", "Database")
@@ -203,6 +213,8 @@ def init_dependencies(
asset_store: AssetStore | None = None,
ha_store: HomeAssistantStore | None = None,
ha_manager: HomeAssistantManager | None = None,
game_integration_store: GameIntegrationStore | None = None,
game_event_bus: GameEventBus | None = None,
):
"""Initialize global dependencies."""
_deps.update(
@@ -232,5 +244,7 @@ def init_dependencies(
"asset_store": asset_store,
"ha_store": ha_store,
"ha_manager": ha_manager,
"game_integration_store": game_integration_store,
"game_event_bus": game_event_bus,
}
)
@@ -16,19 +16,19 @@ from wled_controller.api.schemas.automations import (
AutomationListResponse,
AutomationResponse,
AutomationUpdate,
ConditionSchema,
RuleSchema,
)
from wled_controller.core.automations.automation_engine import AutomationEngine
from wled_controller.storage.automation import (
AlwaysCondition,
ApplicationCondition,
Condition,
DisplayStateCondition,
MQTTCondition,
StartupCondition,
SystemIdleCondition,
TimeOfDayCondition,
WebhookCondition,
ApplicationRule,
DisplayStateRule,
HomeAssistantRule,
MQTTRule,
Rule,
StartupRule,
SystemIdleRule,
TimeOfDayRule,
WebhookRule,
)
from wled_controller.storage.automation_store import AutomationStore
from wled_controller.storage.scene_preset_store import ScenePresetStore
@@ -41,69 +41,78 @@ router = APIRouter()
# ===== Helpers =====
def _condition_from_schema(s: ConditionSchema) -> Condition:
_SCHEMA_TO_CONDITION = {
"always": lambda: AlwaysCondition(),
"application": lambda: ApplicationCondition(
def _rule_from_schema(s: RuleSchema) -> Rule:
_SCHEMA_TO_RULE = {
"application": lambda: ApplicationRule(
apps=s.apps or [],
match_type=s.match_type or "running",
),
"time_of_day": lambda: TimeOfDayCondition(
"time_of_day": lambda: TimeOfDayRule(
start_time=s.start_time or "00:00",
end_time=s.end_time or "23:59",
),
"system_idle": lambda: SystemIdleCondition(
"system_idle": lambda: SystemIdleRule(
idle_minutes=s.idle_minutes if s.idle_minutes is not None else 5,
when_idle=s.when_idle if s.when_idle is not None else True,
),
"display_state": lambda: DisplayStateCondition(
"display_state": lambda: DisplayStateRule(
state=s.state or "on",
),
"mqtt": lambda: MQTTCondition(
"mqtt": lambda: MQTTRule(
topic=s.topic or "",
payload=s.payload or "",
match_mode=s.match_mode or "exact",
),
"webhook": lambda: WebhookCondition(
"webhook": lambda: WebhookRule(
token=s.token or secrets.token_hex(16),
),
"startup": lambda: StartupCondition(),
"startup": lambda: StartupRule(),
"home_assistant": lambda: HomeAssistantRule(
ha_source_id=s.ha_source_id or "",
entity_id=s.entity_id or "",
state=s.state or "",
match_mode=s.match_mode or "exact",
),
}
factory = _SCHEMA_TO_CONDITION.get(s.condition_type)
factory = _SCHEMA_TO_RULE.get(s.rule_type)
if factory is None:
raise ValueError(f"Unknown condition type: {s.condition_type}")
raise ValueError(f"Unknown rule type: {s.rule_type}")
return factory()
def _condition_to_schema(c: Condition) -> ConditionSchema:
d = c.to_dict()
return ConditionSchema(**d)
def _rule_to_schema(r: Rule) -> RuleSchema:
d = r.to_dict()
return RuleSchema(**d)
def _automation_to_response(automation, engine: AutomationEngine, request: Request = None) -> AutomationResponse:
def _automation_to_response(
automation, engine: AutomationEngine, request: Request = None
) -> AutomationResponse:
state = engine.get_automation_state(automation.id)
# Build webhook URL from the first webhook condition (if any)
# Build webhook URL from the first webhook rule (if any)
webhook_url = None
for c in automation.conditions:
if isinstance(c, WebhookCondition) and c.token:
for r in automation.rules:
if isinstance(r, WebhookRule) and r.token:
# Prefer configured external URL, fall back to request base URL
from wled_controller.api.routes.system import load_external_url
ext = load_external_url()
if ext:
webhook_url = ext + f"/api/v1/webhooks/{c.token}"
webhook_url = ext + f"/api/v1/webhooks/{r.token}"
elif request:
webhook_url = str(request.base_url).rstrip("/") + f"/api/v1/webhooks/{c.token}"
webhook_url = str(request.base_url).rstrip("/") + f"/api/v1/webhooks/{r.token}"
else:
webhook_url = f"/api/v1/webhooks/{c.token}"
webhook_url = f"/api/v1/webhooks/{r.token}"
break
return AutomationResponse(
id=automation.id,
name=automation.name,
enabled=automation.enabled,
condition_logic=automation.condition_logic,
conditions=[_condition_to_schema(c) for c in automation.conditions],
rule_logic=automation.rule_logic,
rules=[_rule_to_schema(r) for r in automation.rules],
scene_preset_id=automation.scene_preset_id,
deactivation_mode=automation.deactivation_mode,
deactivation_scene_preset_id=automation.deactivation_scene_preset_id,
@@ -117,9 +126,11 @@ def _automation_to_response(automation, engine: AutomationEngine, request: Reque
)
def _validate_condition_logic(logic: str) -> None:
def _validate_rule_logic(logic: str) -> None:
if logic not in ("or", "and"):
raise HTTPException(status_code=400, detail=f"Invalid condition_logic: {logic}. Must be 'or' or 'and'.")
raise HTTPException(
status_code=400, detail=f"Invalid rule_logic: {logic}. Must be 'or' or 'and'."
)
def _validate_scene_refs(
@@ -136,11 +147,14 @@ def _validate_scene_refs(
try:
scene_store.get_preset(sid)
except ValueError:
raise HTTPException(status_code=400, detail=f"Scene preset not found: {sid} ({label})")
raise HTTPException(
status_code=400, detail=f"Scene preset not found: {sid} ({label})"
)
# ===== CRUD Endpoints =====
@router.post(
"/api/v1/automations",
response_model=AutomationResponse,
@@ -156,11 +170,11 @@ async def create_automation(
scene_store: ScenePresetStore = Depends(get_scene_preset_store),
):
"""Create a new automation."""
_validate_condition_logic(data.condition_logic)
_validate_rule_logic(data.rule_logic)
_validate_scene_refs(data.scene_preset_id, data.deactivation_scene_preset_id, scene_store)
try:
conditions = [_condition_from_schema(c) for c in data.conditions]
rules = [_rule_from_schema(r) for r in data.rules]
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
@@ -170,8 +184,8 @@ async def create_automation(
automation = store.create_automation(
name=data.name,
enabled=data.enabled,
condition_logic=data.condition_logic,
conditions=conditions,
rule_logic=data.rule_logic,
rules=rules,
scene_preset_id=data.scene_preset_id,
deactivation_mode=data.deactivation_mode,
deactivation_scene_preset_id=data.deactivation_scene_preset_id,
@@ -240,16 +254,16 @@ async def update_automation(
scene_store: ScenePresetStore = Depends(get_scene_preset_store),
):
"""Update an automation."""
if data.condition_logic is not None:
_validate_condition_logic(data.condition_logic)
if data.rule_logic is not None:
_validate_rule_logic(data.rule_logic)
# Validate scene refs (only the ones being updated)
_validate_scene_refs(data.scene_preset_id, data.deactivation_scene_preset_id, scene_store)
conditions = None
if data.conditions is not None:
rules = None
if data.rules is not None:
try:
conditions = [_condition_from_schema(c) for c in data.conditions]
rules = [_rule_from_schema(r) for r in data.rules]
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
@@ -266,8 +280,8 @@ async def update_automation(
automation_id=automation_id,
name=data.name,
enabled=data.enabled,
condition_logic=data.condition_logic,
conditions=conditions,
rule_logic=data.rule_logic,
rules=rules,
deactivation_mode=data.deactivation_mode,
tags=data.tags,
)
@@ -280,7 +294,7 @@ async def update_automation(
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
# Re-evaluate immediately if automation is enabled (may have new conditions/scene)
# Re-evaluate immediately if automation is enabled (may have new rules/scene)
if automation.enabled:
await engine.trigger_evaluate()
@@ -313,6 +327,7 @@ async def delete_automation(
# ===== Enable/Disable =====
@router.post(
"/api/v1/automations/{automation_id}/enable",
response_model=AutomationResponse,
@@ -0,0 +1,661 @@
"""Game integration API routes.
CRUD for game integration configs, event ingestion endpoint,
adapter metadata, and diagnostics.
"""
import threading
import time
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Request
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import (
fire_entity_event,
get_database,
get_game_integration_store,
get_game_event_bus,
)
from wled_controller.api.schemas.game_integration import (
AdapterInfoResponse,
AdapterListResponse,
ApplyPresetRequest,
AutoSetupResponse,
EffectPresetResponse,
EventMappingSchema,
GameEventPayload,
GameEventResponse,
GameIntegrationCreate,
GameIntegrationListResponse,
GameIntegrationResponse,
GameIntegrationStatusResponse,
GameIntegrationUpdate,
PresetListResponse,
RecentEventsResponse,
)
from wled_controller.core.game_integration.adapter_registry import AdapterRegistry
from wled_controller.core.game_integration.event_bus import GameEventBus
from wled_controller.core.game_integration.events import GameEvent
from wled_controller.storage.base_store import EntityNotFoundError
from wled_controller.storage.game_integration import EventMapping
from wled_controller.storage.game_integration_store import GameIntegrationStore
from wled_controller.utils import get_logger
logger = get_logger(__name__)
router = APIRouter()
# ── Per-integration runtime state (in-memory, not persisted) ──────────────
_integration_state_lock = threading.Lock()
# integration_id -> prev_state dict for diff-based trigger detection
_prev_states: dict[str, dict[str, Any]] = {}
# integration_id -> runtime stats
_integration_stats: dict[str, dict[str, Any]] = {}
def _schema_to_fields(schema: dict[str, Any]) -> list[dict[str, Any]]:
"""Convert a JSON Schema object into a flat list of field descriptors.
The frontend expects [{name, type, label, default, required, hint}, ...].
"""
properties = schema.get("properties", {})
required_set = set(schema.get("required", []))
fields: list[dict[str, Any]] = []
for name, prop in properties.items():
field: dict[str, Any] = {
"name": name,
"type": prop.get("type", "string"),
"label": prop.get("title", name),
}
if "default" in prop:
field["default"] = prop["default"]
if name in required_set:
field["required"] = True
desc = prop.get("description")
if desc:
field["hint"] = desc
fields.append(field)
return fields
def _get_prev_state(integration_id: str) -> dict[str, Any]:
"""Get or create the prev_state dict for an integration."""
with _integration_state_lock:
if integration_id not in _prev_states:
_prev_states[integration_id] = {}
return _prev_states[integration_id]
def _set_prev_state(integration_id: str, state: dict[str, Any]) -> None:
"""Update the prev_state dict for an integration."""
with _integration_state_lock:
_prev_states[integration_id] = state
def _record_events(integration_id: str, events: list[GameEvent]) -> None:
"""Record event stats for an integration."""
with _integration_state_lock:
if integration_id not in _integration_stats:
_integration_stats[integration_id] = {
"event_count": 0,
"event_counts_by_type": {},
"last_event_time": None,
}
stats = _integration_stats[integration_id]
for event in events:
stats["event_count"] += 1
stats["event_counts_by_type"][event.event_type] = (
stats["event_counts_by_type"].get(event.event_type, 0) + 1
)
stats["last_event_time"] = event.timestamp
def _get_stats(integration_id: str) -> dict[str, Any]:
"""Get runtime stats for an integration."""
with _integration_state_lock:
return _integration_stats.get(
integration_id,
{"event_count": 0, "event_counts_by_type": {}, "last_event_time": None},
)
def _cleanup_state(integration_id: str) -> None:
"""Remove runtime state for a deleted integration."""
with _integration_state_lock:
_prev_states.pop(integration_id, None)
_integration_stats.pop(integration_id, None)
# ── Helper: convert config to response ────────────────────────────────────
def _config_to_response(config: Any) -> GameIntegrationResponse:
"""Convert a GameIntegrationConfig to its API response."""
from wled_controller.api.schemas.game_integration import EventMappingSchema
return GameIntegrationResponse(
id=config.id,
name=config.name,
adapter_type=config.adapter_type,
enabled=config.enabled,
adapter_config=config.adapter_config,
event_mappings=[
EventMappingSchema(
event_type=m.event_type,
effect=m.effect,
color=m.color,
duration_ms=m.duration_ms,
intensity=m.intensity,
priority=m.priority,
)
for m in config.event_mappings
],
created_at=config.created_at,
updated_at=config.updated_at,
description=config.description,
tags=config.tags,
)
# ── Effect Presets (must be before /{integration_id} routes) ────────────
@router.get(
"/api/v1/game-integrations/presets",
response_model=PresetListResponse,
tags=["Game Integration"],
)
async def list_presets(_auth: AuthRequired):
"""List all available built-in effect presets."""
from wled_controller.core.game_integration.presets import get_all_presets
presets = get_all_presets()
responses = [
EffectPresetResponse(
key=p.key,
name=p.name,
description=p.description,
target_game_types=list(p.target_game_types),
event_mappings=[
EventMappingSchema(
event_type=m.event_type,
effect=m.effect,
color=list(m.color),
duration_ms=m.duration_ms,
intensity=m.intensity,
priority=m.priority,
)
for m in p.event_mappings
],
)
for p in presets
]
return PresetListResponse(presets=responses, count=len(responses))
# ── CRUD Endpoints ────────────────────────────────────────────────────────
@router.get(
"/api/v1/game-integrations",
response_model=GameIntegrationListResponse,
tags=["Game Integration"],
)
async def list_integrations(
_auth: AuthRequired,
store: GameIntegrationStore = Depends(get_game_integration_store),
):
"""List all game integration configs."""
try:
configs = store.get_all_integrations()
responses = [_config_to_response(c) for c in configs]
return GameIntegrationListResponse(
integrations=responses,
count=len(responses),
)
except Exception as e:
logger.error("Failed to list game integrations: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
@router.post(
"/api/v1/game-integrations",
response_model=GameIntegrationResponse,
tags=["Game Integration"],
status_code=201,
)
async def create_integration(
data: GameIntegrationCreate,
_auth: AuthRequired,
store: GameIntegrationStore = Depends(get_game_integration_store),
):
"""Create a new game integration config."""
try:
mappings = [
EventMapping(
event_type=m.event_type,
effect=m.effect,
color=list(m.color),
duration_ms=m.duration_ms,
intensity=m.intensity,
priority=m.priority,
)
for m in data.event_mappings
]
config = store.create_integration(
name=data.name,
adapter_type=data.adapter_type,
enabled=data.enabled,
adapter_config=data.adapter_config,
event_mappings=mappings,
description=data.description,
tags=data.tags,
)
fire_entity_event("game_integration", "created", config.id)
return _config_to_response(config)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error("Failed to create game integration: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
@router.get(
"/api/v1/game-integrations/{integration_id}",
response_model=GameIntegrationResponse,
tags=["Game Integration"],
)
async def get_integration(
integration_id: str,
_auth: AuthRequired,
store: GameIntegrationStore = Depends(get_game_integration_store),
):
"""Get a game integration config by ID."""
try:
config = store.get_integration(integration_id)
return _config_to_response(config)
except EntityNotFoundError:
raise HTTPException(status_code=404, detail=f"Game integration {integration_id} not found")
@router.put(
"/api/v1/game-integrations/{integration_id}",
response_model=GameIntegrationResponse,
tags=["Game Integration"],
)
async def update_integration(
integration_id: str,
data: GameIntegrationUpdate,
_auth: AuthRequired,
store: GameIntegrationStore = Depends(get_game_integration_store),
):
"""Update a game integration config."""
try:
mappings = None
if data.event_mappings is not None:
mappings = [
EventMapping(
event_type=m.event_type,
effect=m.effect,
color=list(m.color),
duration_ms=m.duration_ms,
intensity=m.intensity,
priority=m.priority,
)
for m in data.event_mappings
]
config = store.update_integration(
integration_id=integration_id,
name=data.name,
adapter_type=data.adapter_type,
enabled=data.enabled,
adapter_config=data.adapter_config,
event_mappings=mappings,
description=data.description,
tags=data.tags,
)
fire_entity_event("game_integration", "updated", integration_id)
return _config_to_response(config)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error("Failed to update game integration: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
@router.delete(
"/api/v1/game-integrations/{integration_id}",
status_code=204,
tags=["Game Integration"],
)
async def delete_integration(
integration_id: str,
_auth: AuthRequired,
store: GameIntegrationStore = Depends(get_game_integration_store),
):
"""Delete a game integration config."""
try:
store.delete_integration(integration_id)
_cleanup_state(integration_id)
fire_entity_event("game_integration", "deleted", integration_id)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error("Failed to delete game integration: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
# ── Event Ingestion ───────────────────────────────────────────────────────
@router.post(
"/api/v1/game-integrations/{integration_id}/event",
tags=["Game Integration"],
status_code=204,
)
async def ingest_event(
integration_id: str,
payload: GameEventPayload,
request: Request,
store: GameIntegrationStore = Depends(get_game_integration_store),
event_bus: GameEventBus = Depends(get_game_event_bus),
):
"""Receive a game event payload from a game client.
This endpoint is designed for low-latency ingestion (games send at
16-64 Hz). Auth is adapter-level: the adapter's validate_auth() is
called before standard API auth.
No AuthRequired dependency — adapter-level auth is used instead.
"""
try:
config = store.get_integration(integration_id)
except EntityNotFoundError:
raise HTTPException(status_code=404, detail=f"Game integration {integration_id} not found")
if not config.enabled:
raise HTTPException(status_code=409, detail="Integration is disabled")
# Look up adapter
try:
adapter_cls = AdapterRegistry.get_adapter(config.adapter_type)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
# Adapter-level auth check
headers = dict(request.headers)
if not adapter_cls.validate_auth(headers, payload.data, config.adapter_config):
raise HTTPException(status_code=403, detail="Adapter authentication failed")
# Parse payload through adapter
prev_state = _get_prev_state(integration_id)
try:
events, new_state = adapter_cls.parse_payload(
payload.data, config.adapter_config, prev_state
)
except Exception as e:
logger.error(
"Adapter %s failed to parse payload for %s: %s",
config.adapter_type,
integration_id,
e,
)
raise HTTPException(status_code=400, detail=f"Failed to parse payload: {e}")
_set_prev_state(integration_id, new_state)
# Publish events to the bus
for event in events:
event_bus.publish(event)
# Track stats
if events:
_record_events(integration_id, events)
# ── Status / Diagnostics ─────────────────────────────────────────────────
@router.get(
"/api/v1/game-integrations/{integration_id}/status",
response_model=GameIntegrationStatusResponse,
tags=["Game Integration"],
)
async def get_integration_status(
integration_id: str,
_auth: AuthRequired,
store: GameIntegrationStore = Depends(get_game_integration_store),
):
"""Get runtime status for a game integration."""
try:
config = store.get_integration(integration_id)
except EntityNotFoundError:
raise HTTPException(status_code=404, detail=f"Game integration {integration_id} not found")
stats = _get_stats(integration_id)
# Consider "connected" if we received an event in the last 30 seconds
last_event_time = stats["last_event_time"]
connected = False
if last_event_time is not None:
connected = (time.monotonic() - last_event_time) < 30.0
return GameIntegrationStatusResponse(
integration_id=integration_id,
enabled=config.enabled,
connected=connected,
last_event_time=last_event_time,
event_count=stats["event_count"],
event_counts_by_type=stats["event_counts_by_type"],
)
@router.get(
"/api/v1/game-integrations/{integration_id}/events",
response_model=RecentEventsResponse,
tags=["Game Integration"],
)
async def get_recent_events(
integration_id: str,
_auth: AuthRequired,
store: GameIntegrationStore = Depends(get_game_integration_store),
event_bus: GameEventBus = Depends(get_game_event_bus),
limit: int = 50,
):
"""Get recent events for a game integration (for debugging)."""
try:
store.get_integration(integration_id)
except EntityNotFoundError:
raise HTTPException(status_code=404, detail=f"Game integration {integration_id} not found")
# Filter bus recent events to this integration
all_recent = event_bus.get_recent_events(limit=200)
filtered = [e for e in all_recent if e.adapter_id == integration_id][-limit:]
event_responses = [
GameEventResponse(
adapter_id=e.adapter_id,
event_type=e.event_type,
value=e.value,
timestamp=e.timestamp,
raw_data=e.raw_data,
)
for e in filtered
]
return RecentEventsResponse(
integration_id=integration_id,
events=event_responses,
count=len(event_responses),
)
# ── Adapter Metadata ─────────────────────────────────────────────────────
@router.get(
"/api/v1/game-adapters",
response_model=AdapterListResponse,
tags=["Game Integration"],
)
async def list_adapters(_auth: AuthRequired):
"""List all available game adapter types with metadata."""
try:
all_adapters = AdapterRegistry.get_all_adapters()
responses = []
for adapter_type, adapter_cls in all_adapters.items():
responses.append(
AdapterInfoResponse(
adapter_type=adapter_type,
display_name=adapter_cls.DISPLAY_NAME,
game_name=adapter_cls.GAME_NAME,
supported_events=list(adapter_cls.SUPPORTED_EVENTS),
config_schema=_schema_to_fields(adapter_cls.get_config_schema()),
setup_instructions=adapter_cls.get_setup_instructions(),
supports_auto_setup=adapter_cls.supports_auto_setup(),
)
)
return AdapterListResponse(adapters=responses, count=len(responses))
except Exception as e:
logger.error("Failed to list adapters: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
# ── Apply Preset ────────────────────────────────────────────────────────
@router.post(
"/api/v1/game-integrations/{integration_id}/apply-preset",
response_model=GameIntegrationResponse,
tags=["Game Integration"],
)
async def apply_preset(
integration_id: str,
data: ApplyPresetRequest,
_auth: AuthRequired,
store: GameIntegrationStore = Depends(get_game_integration_store),
):
"""Apply a built-in preset to a game integration.
If replace=true, replaces all existing mappings.
If replace=false (default), appends preset mappings to existing ones.
"""
from wled_controller.core.game_integration.presets import get_preset
try:
config = store.get_integration(integration_id)
except EntityNotFoundError:
raise HTTPException(status_code=404, detail=f"Game integration {integration_id} not found")
preset = get_preset(data.preset_key)
if preset is None:
raise HTTPException(status_code=404, detail=f"Preset '{data.preset_key}' not found")
if data.replace:
new_mappings = list(preset.event_mappings)
else:
new_mappings = list(config.event_mappings) + list(preset.event_mappings)
try:
updated = store.update_integration(
integration_id=integration_id,
event_mappings=new_mappings,
)
fire_entity_event("game_integration", "updated", integration_id)
return _config_to_response(updated)
except Exception as e:
logger.error("Failed to apply preset: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Internal server error")
# ── Auto Setup ─────────────────────────────────────────────────────────
@router.post(
"/api/v1/game-integrations/{integration_id}/auto-setup",
response_model=AutoSetupResponse,
tags=["Game Integration"],
)
async def auto_setup_integration(
integration_id: str,
request: Request,
_auth: AuthRequired,
store: GameIntegrationStore = Depends(get_game_integration_store),
):
"""Automatically write game config files for an integration.
Detects the game installation and writes the GSI config file.
Generates an auth token if not already set.
"""
try:
config = store.get_integration(integration_id)
except EntityNotFoundError:
raise HTTPException(status_code=404, detail=f"Game integration {integration_id} not found")
# Look up adapter
try:
adapter_cls = AdapterRegistry.get_adapter(config.adapter_type)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
if not adapter_cls.supports_auto_setup():
raise HTTPException(
status_code=400,
detail=f"Adapter '{config.adapter_type}' does not support auto setup",
)
# Determine server URL
from wled_controller.api.routes.system_settings import load_external_url
db = get_database()
server_url = load_external_url(db)
if not server_url:
host = request.headers.get("host", "localhost:8080")
server_url = f"http://{host}"
# Run auto-setup
try:
result = adapter_cls.auto_setup(
integration_id=integration_id,
adapter_config=config.adapter_config,
server_url=server_url,
)
except Exception as e:
logger.error("Auto setup failed for %s: %s", integration_id, e, exc_info=True)
raise HTTPException(status_code=500, detail=f"Auto setup failed: {e}")
# If a new token was generated, persist the updated adapter_config
if result.get("token_generated") and result.get("adapter_config"):
try:
store.update_integration(
integration_id=integration_id,
adapter_config=result["adapter_config"],
)
fire_entity_event("game_integration", "updated", integration_id)
except Exception as e:
logger.error(
"Failed to save auto-generated token for %s: %s",
integration_id,
e,
)
return AutoSetupResponse(
success=result.get("success", False),
file_path=result.get("file_path", ""),
message=result.get("message", ""),
token_generated=result.get("token_generated", False),
)
@@ -6,58 +6,58 @@ from typing import List, Optional
from pydantic import BaseModel, Field
class ConditionSchema(BaseModel):
"""A single condition within an automation."""
class RuleSchema(BaseModel):
"""A single rule within an automation."""
condition_type: str = Field(description="Condition type discriminator (e.g. 'application')")
# Application condition fields
apps: Optional[List[str]] = Field(None, description="Process names (for application condition)")
rule_type: str = Field(description="Rule type discriminator (e.g. 'application')")
# Application rule fields
apps: Optional[List[str]] = Field(None, description="Process names (for application rule)")
match_type: Optional[str] = Field(
None, description="'running' or 'topmost' (for application condition)"
None, description="'running' or 'topmost' (for application rule)"
)
# Time-of-day condition fields
start_time: Optional[str] = Field(
None, description="Start time HH:MM (for time_of_day condition)"
)
end_time: Optional[str] = Field(None, description="End time HH:MM (for time_of_day condition)")
# System idle condition fields
# Time-of-day rule fields
start_time: Optional[str] = Field(None, description="Start time HH:MM (for time_of_day rule)")
end_time: Optional[str] = Field(None, description="End time HH:MM (for time_of_day rule)")
# System idle rule fields
idle_minutes: Optional[int] = Field(
None, description="Idle timeout in minutes (for system_idle condition)"
None, description="Idle timeout in minutes (for system_idle rule)"
)
when_idle: Optional[bool] = Field(
None, description="True=active when idle (for system_idle condition)"
None, description="True=active when idle (for system_idle rule)"
)
# Display state condition fields
state: Optional[str] = Field(None, description="'on' or 'off' (for display_state condition)")
# MQTT condition fields
topic: Optional[str] = Field(None, description="MQTT topic to watch (for mqtt condition)")
payload: Optional[str] = Field(None, description="Expected payload value (for mqtt condition)")
# Display state rule fields
state: Optional[str] = Field(None, description="'on' or 'off' (for display_state rule)")
# MQTT rule fields
topic: Optional[str] = Field(None, description="MQTT topic to watch (for mqtt rule)")
payload: Optional[str] = Field(None, description="Expected payload value (for mqtt rule)")
match_mode: Optional[str] = Field(
None, description="'exact', 'contains', or 'regex' (for mqtt condition)"
None, description="'exact', 'contains', or 'regex' (for mqtt rule)"
)
# Webhook condition fields
# Webhook rule fields
token: Optional[str] = Field(
None, description="Secret token for webhook URL (for webhook condition)"
None, description="Secret token for webhook URL (for webhook rule)"
)
# Home Assistant condition fields
# Home Assistant rule fields
ha_source_id: Optional[str] = Field(
None, description="Home Assistant source ID (for home_assistant condition)"
None, description="Home Assistant source ID (for home_assistant rule)"
)
entity_id: Optional[str] = Field(
None,
description="HA entity ID, e.g. 'binary_sensor.front_door' (for home_assistant condition)",
description="HA entity ID, e.g. 'binary_sensor.front_door' (for home_assistant rule)",
)
# Backward-compatible alias
ConditionSchema = RuleSchema
class AutomationCreate(BaseModel):
"""Request to create an automation."""
name: str = Field(description="Automation name", min_length=1, max_length=100)
enabled: bool = Field(default=True, description="Whether the automation is enabled")
condition_logic: str = Field(default="or", description="How conditions combine: 'or' or 'and'")
conditions: List[ConditionSchema] = Field(
default_factory=list, description="List of conditions"
)
rule_logic: str = Field(default="or", description="How rules combine: 'or' or 'and'")
rules: List[RuleSchema] = Field(default_factory=list, description="List of rules")
scene_preset_id: Optional[str] = Field(None, description="Scene preset to activate")
deactivation_mode: str = Field(
default="none", description="'none', 'revert', or 'fallback_scene'"
@@ -73,10 +73,8 @@ class AutomationUpdate(BaseModel):
name: Optional[str] = Field(None, description="Automation name", min_length=1, max_length=100)
enabled: Optional[bool] = Field(None, description="Whether the automation is enabled")
condition_logic: Optional[str] = Field(
None, description="How conditions combine: 'or' or 'and'"
)
conditions: Optional[List[ConditionSchema]] = Field(None, description="List of conditions")
rule_logic: Optional[str] = Field(None, description="How rules combine: 'or' or 'and'")
rules: Optional[List[RuleSchema]] = Field(None, description="List of rules")
scene_preset_id: Optional[str] = Field(None, description="Scene preset to activate")
deactivation_mode: Optional[str] = Field(
None, description="'none', 'revert', or 'fallback_scene'"
@@ -93,14 +91,14 @@ class AutomationResponse(BaseModel):
id: str = Field(description="Automation ID")
name: str = Field(description="Automation name")
enabled: bool = Field(description="Whether the automation is enabled")
condition_logic: str = Field(description="Condition combination logic")
conditions: List[ConditionSchema] = Field(description="List of conditions")
rule_logic: str = Field(description="Rule combination logic")
rules: List[RuleSchema] = Field(description="List of rules")
scene_preset_id: Optional[str] = Field(None, description="Scene preset to activate")
deactivation_mode: str = Field(default="none", description="Deactivation behavior")
deactivation_scene_preset_id: Optional[str] = Field(None, description="Fallback scene preset")
tags: List[str] = Field(default_factory=list, description="User-defined tags")
webhook_url: Optional[str] = Field(
None, description="Webhook URL for the first webhook condition (if any)"
None, description="Webhook URL for the first webhook rule (if any)"
)
is_active: bool = Field(default=False, description="Whether the automation is currently active")
last_activated_at: Optional[datetime] = Field(
@@ -0,0 +1,194 @@
"""Pydantic schemas for game integration API endpoints."""
from datetime import datetime
from typing import Any, Dict, List, Optional
from pydantic import BaseModel, Field
# ── Event Mapping ──────────────────────────────────────────────────────────
class EventMappingSchema(BaseModel):
"""Maps a standard game event type to a visual effect."""
event_type: str = Field(description="Standard event type (e.g. 'health', 'kill')")
effect: str = Field(default="flash", description="Effect name (flash, pulse, gradient)")
color: List[int] = Field(
default=[255, 0, 0],
description="RGB color [R, G, B] (0-255 each)",
min_length=3,
max_length=3,
)
duration_ms: int = Field(default=500, ge=0, le=60000, description="Effect duration in ms")
intensity: float = Field(default=1.0, ge=0.0, le=1.0, description="Effect intensity 0.0-1.0")
priority: int = Field(default=0, ge=0, le=100, description="Priority for effect stacking")
# ── CRUD Schemas ───────────────────────────────────────────────────────────
class GameIntegrationCreate(BaseModel):
"""Request to create a game integration config."""
name: str = Field(description="Integration name", min_length=1, max_length=100)
adapter_type: str = Field(description="Adapter type identifier", min_length=1)
enabled: bool = Field(default=True, description="Whether integration is active")
adapter_config: Dict[str, Any] = Field(
default_factory=dict, description="Adapter-specific settings"
)
event_mappings: List[EventMappingSchema] = Field(
default_factory=list, description="Event-to-effect mappings"
)
description: Optional[str] = Field(None, description="Integration description", max_length=500)
tags: List[str] = Field(default_factory=list, description="User-defined tags")
class GameIntegrationUpdate(BaseModel):
"""Request to update a game integration config."""
name: Optional[str] = Field(None, description="Integration name", min_length=1, max_length=100)
adapter_type: Optional[str] = Field(None, description="Adapter type identifier", min_length=1)
enabled: Optional[bool] = Field(None, description="Whether integration is active")
adapter_config: Optional[Dict[str, Any]] = Field(None, description="Adapter-specific settings")
event_mappings: Optional[List[EventMappingSchema]] = Field(
None, description="Event-to-effect mappings"
)
description: Optional[str] = Field(None, description="Integration description", max_length=500)
tags: Optional[List[str]] = Field(None, description="User-defined tags")
class GameIntegrationResponse(BaseModel):
"""Game integration config response."""
id: str = Field(description="Integration ID")
name: str = Field(description="Integration name")
adapter_type: str = Field(description="Adapter type identifier")
enabled: bool = Field(description="Whether integration is active")
adapter_config: Dict[str, Any] = Field(description="Adapter-specific settings")
event_mappings: List[EventMappingSchema] = Field(description="Event-to-effect mappings")
created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp")
description: Optional[str] = Field(None, description="Integration description")
tags: List[str] = Field(default_factory=list, description="User-defined tags")
class GameIntegrationListResponse(BaseModel):
"""List of game integration configs."""
integrations: List[GameIntegrationResponse] = Field(
description="List of game integration configs"
)
count: int = Field(description="Number of integrations")
# ── Event Ingestion ────────────────────────────────────────────────────────
class GameEventPayload(BaseModel):
"""Incoming game event payload from a game client.
The shape depends on the adapter — this is a generic envelope.
The adapter's parse_payload() extracts standardized events.
"""
data: Dict[str, Any] = Field(description="Raw game event data")
# ── Adapter Metadata ───────────────────────────────────────────────────────
class AdapterInfoResponse(BaseModel):
"""Metadata for a registered game adapter."""
adapter_type: str = Field(description="Adapter type identifier")
display_name: str = Field(description="Human-readable adapter name")
game_name: str = Field(description="Game this adapter supports")
supported_events: List[str] = Field(description="Standard event types supported")
config_schema: List[Dict[str, Any]] = Field(description="Flat list of config fields")
setup_instructions: str = Field(description="Markdown setup guide")
supports_auto_setup: bool = Field(
default=False, description="Whether this adapter supports automatic config setup"
)
class AdapterListResponse(BaseModel):
"""List of available game adapters."""
adapters: List[AdapterInfoResponse] = Field(description="Available adapters")
count: int = Field(description="Number of adapters")
# ── Status / Diagnostics ──────────────────────────────────────────────────
class GameIntegrationStatusResponse(BaseModel):
"""Runtime status for a game integration."""
integration_id: str = Field(description="Integration ID")
enabled: bool = Field(description="Whether integration is active")
connected: bool = Field(description="Whether adapter is currently receiving data")
last_event_time: Optional[float] = Field(None, description="Monotonic timestamp of last event")
event_count: int = Field(default=0, description="Total events received")
event_counts_by_type: Dict[str, int] = Field(
default_factory=dict, description="Event counts per event type"
)
class GameEventResponse(BaseModel):
"""A single game event for diagnostics."""
adapter_id: str = Field(description="Adapter that produced this event")
event_type: str = Field(description="Standard event type")
value: float = Field(description="Normalized value 0.0-1.0")
timestamp: float = Field(description="Monotonic timestamp")
raw_data: Dict[str, Any] = Field(default_factory=dict, description="Original game data")
class RecentEventsResponse(BaseModel):
"""Recent events for a game integration."""
integration_id: str = Field(description="Integration ID")
events: List[GameEventResponse] = Field(description="Recent events (newest last)")
count: int = Field(description="Number of events returned")
# ── Presets ──────────────────────────────────────────────────────────────
class EffectPresetResponse(BaseModel):
"""A built-in effect preset."""
key: str = Field(description="Unique preset key")
name: str = Field(description="Display name")
description: str = Field(description="One-line description")
target_game_types: List[str] = Field(description="Genre tags (fps, moba, racing, any)")
event_mappings: List[EventMappingSchema] = Field(description="Pre-configured mappings")
class PresetListResponse(BaseModel):
"""List of available effect presets."""
presets: List[EffectPresetResponse] = Field(description="Available presets")
count: int = Field(description="Number of presets")
class ApplyPresetRequest(BaseModel):
"""Request to apply a preset to an integration."""
preset_key: str = Field(description="Key of the preset to apply")
replace: bool = Field(
default=False,
description="If true, replace existing mappings; if false, append",
)
class AutoSetupResponse(BaseModel):
"""Result of an auto-setup operation."""
success: bool = Field(description="Whether the setup completed successfully")
file_path: str = Field(default="", description="Path to the config file written")
message: str = Field(description="Human-readable result message")
token_generated: bool = Field(
default=False, description="Whether a new auth token was auto-generated"
)
@@ -1,4 +1,4 @@
"""Automation engine — background loop that evaluates conditions and activates scenes."""
"""Automation engine — background loop that evaluates rules and activates scenes."""
import asyncio
import re
@@ -7,17 +7,16 @@ from typing import Dict, Optional, Set
from wled_controller.core.automations.platform_detector import PlatformDetector
from wled_controller.storage.automation import (
AlwaysCondition,
ApplicationCondition,
ApplicationRule,
Automation,
Condition,
DisplayStateCondition,
HomeAssistantCondition,
MQTTCondition,
StartupCondition,
SystemIdleCondition,
TimeOfDayCondition,
WebhookCondition,
DisplayStateRule,
HomeAssistantRule,
MQTTRule,
Rule,
StartupRule,
SystemIdleRule,
TimeOfDayRule,
WebhookRule,
)
from wled_controller.storage.automation_store import AutomationStore
from wled_controller.storage.scene_preset import ScenePreset
@@ -27,7 +26,7 @@ logger = get_logger(__name__)
class AutomationEngine:
"""Evaluates automation conditions and activates/deactivates scene presets."""
"""Evaluates automation rules and activates/deactivates scene presets."""
def __init__(
self,
@@ -62,10 +61,13 @@ class AutomationEngine:
self._last_deactivated: Dict[str, datetime] = {}
# webhook_token → bool (volatile state set by webhook calls)
self._webhook_states: Dict[str, bool] = {}
# HA source IDs currently acquired by the engine
self._ha_acquired: Set[str] = set()
async def start(self) -> None:
if self._task is not None:
return
await self._sync_ha_runtimes()
self._task = asyncio.create_task(self._poll_loop())
logger.info("Automation engine started")
@@ -85,8 +87,55 @@ class AutomationEngine:
for automation_id in list(self._active_automations.keys()):
await self._deactivate_automation(automation_id)
# Release all HA runtimes
await self._release_all_ha_runtimes()
logger.info("Automation engine stopped")
def _get_needed_ha_sources(self) -> Set[str]:
"""Collect HA source IDs referenced by enabled automations."""
needed: Set[str] = set()
if self._ha_manager is None:
return needed
for a in self._store.get_all_automations():
if a.enabled:
for r in a.rules:
if isinstance(r, HomeAssistantRule) and r.ha_source_id:
needed.add(r.ha_source_id)
return needed
async def _sync_ha_runtimes(self) -> None:
"""Acquire/release HA runtimes to match current automation rules."""
if self._ha_manager is None:
return
needed = self._get_needed_ha_sources()
# Release sources no longer needed
for source_id in self._ha_acquired - needed:
try:
await self._ha_manager.release(source_id)
logger.debug("Released HA runtime for automation: %s", source_id)
except Exception as e:
logger.warning("Failed to release HA runtime %s: %s", source_id, e)
# Acquire newly needed sources
for source_id in needed - self._ha_acquired:
try:
await self._ha_manager.acquire(source_id)
logger.debug("Acquired HA runtime for automation: %s", source_id)
except Exception as e:
logger.warning("Failed to acquire HA runtime %s: %s", source_id, e)
self._ha_acquired = needed
async def _release_all_ha_runtimes(self) -> None:
"""Release all HA runtimes held by the engine."""
if self._ha_manager is None:
return
for source_id in self._ha_acquired:
try:
await self._ha_manager.release(source_id)
except Exception as e:
logger.warning("Failed to release HA runtime %s: %s", source_id, e)
self._ha_acquired = set()
async def _poll_loop(self) -> None:
try:
while True:
@@ -100,6 +149,7 @@ class AutomationEngine:
pass
async def _evaluate_all(self) -> None:
await self._sync_ha_runtimes()
async with self._eval_lock:
await self._evaluate_all_locked()
@@ -152,12 +202,12 @@ class AutomationEngine:
needs_display_state = False
for a in automations:
if a.enabled:
for c in a.conditions:
if isinstance(c, ApplicationCondition):
match_types_used.add(c.match_type)
elif isinstance(c, SystemIdleCondition):
for r in a.rules:
if isinstance(r, ApplicationRule):
match_types_used.add(r.match_type)
elif isinstance(r, SystemIdleRule):
needs_idle = True
elif isinstance(c, DisplayStateCondition):
elif isinstance(r, DisplayStateRule):
needs_display_state = True
needs_running = "running" in match_types_used
@@ -187,8 +237,8 @@ class AutomationEngine:
for automation in automations:
should_be_active = automation.enabled and (
len(automation.conditions) == 0
or self._evaluate_conditions(
len(automation.rules) == 0
or self._evaluate_rules(
automation,
running_procs,
topmost_proc,
@@ -214,7 +264,7 @@ class AutomationEngine:
if aid not in active_automation_ids:
await self._deactivate_automation(aid)
def _evaluate_conditions(
def _evaluate_rules(
self,
automation: Automation,
running_procs: Set[str],
@@ -225,8 +275,8 @@ class AutomationEngine:
display_state: Optional[str],
) -> bool:
results = [
self._evaluate_condition(
c,
self._evaluate_rule(
r,
running_procs,
topmost_proc,
topmost_fullscreen,
@@ -234,16 +284,16 @@ class AutomationEngine:
idle_seconds,
display_state,
)
for c in automation.conditions
for r in automation.rules
]
if automation.condition_logic == "and":
if automation.rule_logic == "and":
return all(results)
return any(results) # "or" is default
def _evaluate_condition(
def _evaluate_rule(
self,
condition: Condition,
rule: Rule,
running_procs: Set[str],
topmost_proc: Optional[str],
topmost_fullscreen: bool,
@@ -252,29 +302,28 @@ class AutomationEngine:
display_state: Optional[str],
) -> bool:
dispatch = {
AlwaysCondition: lambda c: True,
StartupCondition: lambda c: True,
ApplicationCondition: lambda c: self._evaluate_app_condition(
c, running_procs, topmost_proc, topmost_fullscreen, fullscreen_procs
StartupRule: lambda r: True,
ApplicationRule: lambda r: self._evaluate_app_rule(
r, running_procs, topmost_proc, topmost_fullscreen, fullscreen_procs
),
TimeOfDayCondition: lambda c: self._evaluate_time_of_day(c),
SystemIdleCondition: lambda c: self._evaluate_idle(c, idle_seconds),
DisplayStateCondition: lambda c: self._evaluate_display_state(c, display_state),
MQTTCondition: lambda c: self._evaluate_mqtt(c),
WebhookCondition: lambda c: self._webhook_states.get(c.token, False),
HomeAssistantCondition: lambda c: self._evaluate_home_assistant(c),
TimeOfDayRule: lambda r: self._evaluate_time_of_day(r),
SystemIdleRule: lambda r: self._evaluate_idle(r, idle_seconds),
DisplayStateRule: lambda r: self._evaluate_display_state(r, display_state),
MQTTRule: lambda r: self._evaluate_mqtt(r),
WebhookRule: lambda r: self._webhook_states.get(r.token, False),
HomeAssistantRule: lambda r: self._evaluate_home_assistant(r),
}
handler = dispatch.get(type(condition))
handler = dispatch.get(type(rule))
if handler is None:
return False
return handler(condition)
return handler(rule)
@staticmethod
def _evaluate_time_of_day(condition: TimeOfDayCondition) -> bool:
def _evaluate_time_of_day(rule: TimeOfDayRule) -> bool:
now = datetime.now()
current = now.hour * 60 + now.minute
parts_s = condition.start_time.split(":")
parts_e = condition.end_time.split(":")
parts_s = rule.start_time.split(":")
parts_e = rule.end_time.split(":")
start = int(parts_s[0]) * 60 + int(parts_s[1])
end = int(parts_e[0]) * 60 + int(parts_e[1])
if start <= end:
@@ -283,73 +332,71 @@ class AutomationEngine:
return current >= start or current <= end
@staticmethod
def _evaluate_idle(condition: SystemIdleCondition, idle_seconds: Optional[float]) -> bool:
def _evaluate_idle(rule: SystemIdleRule, idle_seconds: Optional[float]) -> bool:
if idle_seconds is None:
return False
is_idle = idle_seconds >= (condition.idle_minutes * 60)
return is_idle if condition.when_idle else not is_idle
is_idle = idle_seconds >= (rule.idle_minutes * 60)
return is_idle if rule.when_idle else not is_idle
@staticmethod
def _evaluate_display_state(
condition: DisplayStateCondition, display_state: Optional[str]
) -> bool:
def _evaluate_display_state(rule: DisplayStateRule, display_state: Optional[str]) -> bool:
if display_state is None:
return False
return display_state == condition.state
return display_state == rule.state
def _evaluate_mqtt(self, condition: MQTTCondition) -> bool:
def _evaluate_mqtt(self, rule: MQTTRule) -> bool:
if self._mqtt_service is None or not self._mqtt_service.is_connected:
return False
value = self._mqtt_service.get_last_value(condition.topic)
value = self._mqtt_service.get_last_value(rule.topic)
if value is None:
return False
matchers = {
"exact": lambda: value == condition.payload,
"contains": lambda: condition.payload in value,
"regex": lambda: bool(re.search(condition.payload, value)),
"exact": lambda: value == rule.payload,
"contains": lambda: rule.payload in value,
"regex": lambda: bool(re.search(rule.payload, value)),
}
matcher = matchers.get(condition.match_mode)
matcher = matchers.get(rule.match_mode)
if matcher is None:
return False
try:
return matcher()
except re.error as e:
logger.debug("MQTT condition regex error: %s", e)
logger.debug("MQTT rule regex error: %s", e)
return False
def _evaluate_home_assistant(self, condition: HomeAssistantCondition) -> bool:
def _evaluate_home_assistant(self, rule: HomeAssistantRule) -> bool:
if self._ha_manager is None:
return False
entity_state = self._ha_manager.get_state(condition.ha_source_id, condition.entity_id)
entity_state = self._ha_manager.get_state(rule.ha_source_id, rule.entity_id)
if entity_state is None:
return False
value = entity_state.state
matchers = {
"exact": lambda: value == condition.state,
"contains": lambda: condition.state in value,
"regex": lambda: bool(re.search(condition.state, value)),
"exact": lambda: value == rule.state,
"contains": lambda: rule.state in value,
"regex": lambda: bool(re.search(rule.state, value)),
}
matcher = matchers.get(condition.match_mode)
matcher = matchers.get(rule.match_mode)
if matcher is None:
return False
try:
return matcher()
except re.error as e:
logger.debug("HA condition regex error: %s", e)
logger.debug("HA rule regex error: %s", e)
return False
def _evaluate_app_condition(
def _evaluate_app_rule(
self,
condition: ApplicationCondition,
rule: ApplicationRule,
running_procs: Set[str],
topmost_proc: Optional[str],
topmost_fullscreen: bool,
fullscreen_procs: Set[str],
) -> bool:
if not condition.apps:
if not rule.apps:
return False
apps_lower = [a.lower() for a in condition.apps]
apps_lower = [a.lower() for a in rule.apps]
match_handlers = {
"fullscreen": lambda: any(app in fullscreen_procs for app in apps_lower),
@@ -362,7 +409,7 @@ class AutomationEngine:
topmost_proc is not None and any(app == topmost_proc for app in apps_lower)
),
}
handler = match_handlers.get(condition.match_type)
handler = match_handlers.get(rule.match_type)
if handler is not None:
return handler()
# Default: "running"
@@ -370,7 +417,7 @@ class AutomationEngine:
async def _activate_automation(self, automation: Automation) -> None:
if not automation.scene_preset_id:
# No scene configured — just mark active (conditions matched but nothing to do)
# No scene configured — just mark active (rules matched but nothing to do)
self._active_automations[automation.id] = True
self._last_activated[automation.id] = datetime.now(timezone.utc)
self._fire_event(automation.id, "activated")
@@ -526,7 +573,7 @@ class AutomationEngine:
logger.error(f"Immediate automation evaluation error: {e}", exc_info=True)
async def set_webhook_state(self, token: str, active: bool) -> None:
"""Set webhook condition state and trigger immediate evaluation."""
"""Set webhook rule state and trigger immediate evaluation."""
self._webhook_states[token] = active
await self.trigger_evaluate()
@@ -5,7 +5,6 @@ from typing import Any, Dict, List, Optional
import numpy as np
from wled_controller.config import is_demo_mode
from wled_controller.core.capture_engines.base import (
CaptureEngine,
CaptureStream,
@@ -16,6 +15,8 @@ from wled_controller.utils import get_logger
logger = get_logger(__name__)
SIMULATION_TYPES = ["radial_gradient"]
# Virtual display definitions: (name, width, height, x, y, is_primary)
_VIRTUAL_DISPLAYS = [
("Demo Display 1080p", 1920, 1080, 0, 360, True),
@@ -35,6 +36,7 @@ class DemoCaptureStream(CaptureStream):
def __init__(self, display_index: int, config: Dict[str, Any]):
super().__init__(display_index, config)
self._simulation_type: str = config.get("simulation_type", "radial_gradient")
self._width: int = config.get("width", 1920)
self._height: int = config.get("height", 1080)
# Pre-compute at render resolution
@@ -50,7 +52,7 @@ class DemoCaptureStream(CaptureStream):
self._yy, self._xx = np.meshgrid(y, x, indexing="ij")
# Pre-compute angle (atan2) and radius — they don't change per frame
self._angle = np.arctan2(self._yy, self._xx) # -pi..pi
self._radius = np.sqrt(self._xx ** 2 + self._yy ** 2)
self._radius = np.sqrt(self._xx**2 + self._yy**2)
def initialize(self) -> None:
self._initialized = True
@@ -86,21 +88,37 @@ class DemoCaptureStream(CaptureStream):
q = val * (1.0 - frac)
t_ch = val * frac # "t" channel in HSV conversion
r = np.where(sector == 0, val,
np.where(sector == 1, q,
np.where(sector == 2, 0,
np.where(sector == 3, 0,
np.where(sector == 4, t_ch, val)))))
g = np.where(sector == 0, t_ch,
np.where(sector == 1, val,
np.where(sector == 2, val,
np.where(sector == 3, q,
np.where(sector == 4, 0, 0)))))
b = np.where(sector == 0, 0,
np.where(sector == 1, 0,
np.where(sector == 2, t_ch,
np.where(sector == 3, val,
np.where(sector == 4, val, q)))))
r = np.where(
sector == 0,
val,
np.where(
sector == 1,
q,
np.where(
sector == 2, 0, np.where(sector == 3, 0, np.where(sector == 4, t_ch, val))
),
),
)
g = np.where(
sector == 0,
t_ch,
np.where(
sector == 1,
val,
np.where(sector == 2, val, np.where(sector == 3, q, np.where(sector == 4, 0, 0))),
),
)
b = np.where(
sector == 0,
0,
np.where(
sector == 1,
0,
np.where(
sector == 2, t_ch, np.where(sector == 3, val, np.where(sector == 4, val, q))
),
),
)
small_u8 = (np.stack([r, g, b], axis=-1) * 255.0).astype(np.uint8)
@@ -108,7 +126,8 @@ class DemoCaptureStream(CaptureStream):
if self._RENDER_SCALE > 1:
image = np.repeat(
np.repeat(small_u8, self._RENDER_SCALE, axis=0),
self._RENDER_SCALE, axis=1,
self._RENDER_SCALE,
axis=1,
)[: self._height, : self._width]
else:
image = small_u8
@@ -129,36 +148,40 @@ class DemoCaptureEngine(CaptureEngine):
"""
ENGINE_TYPE = "demo"
ENGINE_PRIORITY = 1000 # Highest priority in demo mode
ENGINE_PRIORITY = 0 # Lowest — never outranks real engines
@classmethod
def is_available(cls) -> bool:
return is_demo_mode()
return True
@classmethod
def get_default_config(cls) -> Dict[str, Any]:
return {}
return {"simulation_type": "radial_gradient"}
@classmethod
def get_available_displays(cls) -> List[DisplayInfo]:
displays = []
for idx, (name, width, height, x, y, primary) in enumerate(_VIRTUAL_DISPLAYS):
displays.append(DisplayInfo(
index=idx,
name=name,
width=width,
height=height,
x=x,
y=y,
is_primary=primary,
refresh_rate=60,
))
displays.append(
DisplayInfo(
index=idx,
name=name,
width=width,
height=height,
x=x,
y=y,
is_primary=primary,
refresh_rate=60,
)
)
logger.debug(f"Demo engine: {len(displays)} virtual display(s)")
return displays
@classmethod
def create_stream(
cls, display_index: int, config: Dict[str, Any],
cls,
display_index: int,
config: Dict[str, Any],
) -> DemoCaptureStream:
if display_index < 0 or display_index >= len(_VIRTUAL_DISPLAYS):
raise ValueError(
@@ -0,0 +1,39 @@
"""Game integration core — event bus, adapters, and event vocabulary.
Re-exports the main public API for convenience:
from wled_controller.core.game_integration import GameEvent, GameEventBus, ...
"""
from wled_controller.core.game_integration.adapter_registry import AdapterRegistry
from wled_controller.core.game_integration.base_adapter import GameAdapter
from wled_controller.core.game_integration.event_bus import GameEventBus
from wled_controller.core.game_integration.events import (
EventCategory,
EventTypeMetadata,
GameEvent,
ValueType,
get_event_metadata,
get_event_vocabulary,
is_known_event_type,
)
from wled_controller.core.game_integration.mapping_adapter import (
MappingAdapter,
load_adapter_from_yaml,
validate_adapter_yaml,
)
__all__ = [
"AdapterRegistry",
"EventCategory",
"EventTypeMetadata",
"GameAdapter",
"GameEvent",
"GameEventBus",
"MappingAdapter",
"ValueType",
"get_event_metadata",
"get_event_vocabulary",
"is_known_event_type",
"load_adapter_from_yaml",
"validate_adapter_yaml",
]
@@ -0,0 +1,98 @@
"""Registry for game integration adapters.
Follows the EngineRegistry pattern from capture_engines/factory.py:
class-level dict, register/get/list methods, clear for testing.
"""
from typing import Type
from wled_controller.core.game_integration.base_adapter import GameAdapter
from wled_controller.utils import get_logger
logger = get_logger(__name__)
class AdapterRegistry:
"""Registry of available game adapters.
Maintains a mapping of adapter_type strings to GameAdapter subclasses.
All methods are classmethods operating on shared class-level state.
"""
_adapters: dict[str, Type[GameAdapter]] = {}
@classmethod
def register(cls, adapter_class: Type[GameAdapter]) -> None:
"""Register a game adapter class.
Args:
adapter_class: Must be a subclass of GameAdapter.
Raises:
ValueError: If not a GameAdapter subclass or has reserved type.
"""
if not (isinstance(adapter_class, type) and issubclass(adapter_class, GameAdapter)):
raise ValueError(f"{adapter_class} must be a subclass of GameAdapter")
adapter_type = adapter_class.ADAPTER_TYPE
if adapter_type == "base":
raise ValueError("Cannot register adapter with reserved type 'base'")
if adapter_type in cls._adapters:
logger.warning(f"Adapter '{adapter_type}' already registered, overwriting")
cls._adapters[adapter_type] = adapter_class
logger.info(f"Registered game adapter: {adapter_type}")
@classmethod
def get_adapter(cls, adapter_type: str) -> Type[GameAdapter]:
"""Look up an adapter class by type.
Args:
adapter_type: Adapter type identifier.
Returns:
The adapter class.
Raises:
ValueError: If the adapter type is not registered.
"""
if adapter_type not in cls._adapters:
available = ", ".join(cls._adapters.keys()) or "none"
raise ValueError(f"Unknown adapter type: '{adapter_type}'. Available: {available}")
return cls._adapters[adapter_type]
@classmethod
def get_all_adapters(cls) -> dict[str, Type[GameAdapter]]:
"""Return all registered adapters (copy).
Returns:
Dict mapping adapter_type to adapter class.
"""
return dict(cls._adapters)
@classmethod
def get_available_adapters(cls) -> list[dict]:
"""Return metadata for all registered adapters.
Returns:
List of dicts with adapter_type, display_name, game_name,
supported_events for each registered adapter.
"""
result = []
for adapter_type, adapter_class in cls._adapters.items():
result.append(
{
"adapter_type": adapter_type,
"display_name": adapter_class.DISPLAY_NAME,
"game_name": adapter_class.GAME_NAME,
"supported_events": list(adapter_class.SUPPORTED_EVENTS),
}
)
return result
@classmethod
def clear_registry(cls) -> None:
"""Clear all registered adapters (for testing)."""
cls._adapters.clear()
logger.debug("Cleared adapter registry")
@@ -0,0 +1,25 @@
"""Built-in game adapters package.
Registers all built-in adapters with the AdapterRegistry on import.
"""
from wled_controller.core.game_integration.adapter_registry import AdapterRegistry
from wled_controller.core.game_integration.adapters.cs2_adapter import CS2Adapter
from wled_controller.core.game_integration.adapters.dota2_adapter import Dota2Adapter
from wled_controller.core.game_integration.adapters.generic_webhook_adapter import (
GenericWebhookAdapter,
)
from wled_controller.core.game_integration.adapters.lol_adapter import LoLAdapter
# Register all built-in adapters
AdapterRegistry.register(CS2Adapter)
AdapterRegistry.register(Dota2Adapter)
AdapterRegistry.register(LoLAdapter)
AdapterRegistry.register(GenericWebhookAdapter)
__all__ = [
"CS2Adapter",
"Dota2Adapter",
"GenericWebhookAdapter",
"LoLAdapter",
]
@@ -0,0 +1,408 @@
"""CS2 (Counter-Strike 2) Game State Integration adapter.
Parses CS2 GSI JSON payloads into standardized GameEvents. CS2 sends
the full game state on each update — this adapter extracts player state,
round info, and uses diff-based detection for kills/deaths.
Ref: https://developer.valvesoftware.com/wiki/Counter-Strike:_Global_Offensive_Game_State_Integration
"""
import secrets
import time
from typing import Any, ClassVar
from wled_controller.core.game_integration.base_adapter import GameAdapter
from wled_controller.core.game_integration.events import GameEvent
from wled_controller.utils import get_logger
logger = get_logger(__name__)
class CS2Adapter(GameAdapter):
"""Adapter for Counter-Strike 2 Game State Integration."""
ADAPTER_TYPE: ClassVar[str] = "cs2"
DISPLAY_NAME: ClassVar[str] = "Counter-Strike 2 GSI"
GAME_NAME: ClassVar[str] = "Counter-Strike 2"
SUPPORTED_EVENTS: ClassVar[list[str]] = [
"health",
"armor",
"ammo",
"gold", # money mapped to gold
"kill",
"death",
"round_start",
"round_end",
"objective_captured", # bomb_planted
"objective_lost", # bomb_defused (from attacker perspective)
"blinded", # flashbang
"team_a", # CT
"team_b", # T
]
@classmethod
def parse_payload(
cls,
payload: dict[str, Any],
adapter_config: dict[str, Any],
prev_state: dict[str, Any],
) -> tuple[list[GameEvent], dict[str, Any]]:
"""Parse a CS2 GSI payload into standardized events."""
events: list[GameEvent] = []
new_state = dict(prev_state)
adapter_id = adapter_config.get("adapter_id", "cs2")
now = time.monotonic()
player = payload.get("player", {})
player_state = player.get("state", {})
match_stats = player.get("match_stats", {})
round_info = payload.get("round", {})
# ── Continuous: health ──
health = player_state.get("health")
if health is not None:
events.append(
GameEvent(
adapter_id=adapter_id,
event_type="health",
value=max(0.0, min(1.0, float(health) / 100.0)),
raw_data={"health": health},
timestamp=now,
)
)
# ── Continuous: armor ──
armor = player_state.get("armor")
if armor is not None:
events.append(
GameEvent(
adapter_id=adapter_id,
event_type="armor",
value=max(0.0, min(1.0, float(armor) / 100.0)),
raw_data={"armor": armor},
timestamp=now,
)
)
# ── Continuous: money (mapped to gold) ──
money = player_state.get("money")
if money is not None:
events.append(
GameEvent(
adapter_id=adapter_id,
event_type="gold",
value=max(0.0, min(1.0, float(money) / 16000.0)),
raw_data={"money": money},
timestamp=now,
)
)
# ── Continuous: ammo ──
# CS2 reports clip ammo in weapon.ammo_clip and reserve in ammo_reserve
weapons = player.get("weapons", {})
active_weapon = _get_active_weapon(weapons)
if active_weapon:
clip = active_weapon.get("ammo_clip")
clip_max = active_weapon.get("ammo_clip_max")
if clip is not None and clip_max and clip_max > 0:
events.append(
GameEvent(
adapter_id=adapter_id,
event_type="ammo",
value=max(0.0, min(1.0, float(clip) / float(clip_max))),
raw_data={"ammo_clip": clip, "ammo_clip_max": clip_max},
timestamp=now,
)
)
# ── Trigger: kills (diff-based) ──
kills = match_stats.get("kills")
if kills is not None:
prev_kills = prev_state.get("kills")
new_state["kills"] = kills
if prev_kills is not None and kills > prev_kills:
for _ in range(kills - prev_kills):
events.append(
GameEvent(
adapter_id=adapter_id,
event_type="kill",
value=1.0,
raw_data={"kills": kills, "prev_kills": prev_kills},
timestamp=now,
)
)
# ── Trigger: deaths (diff-based) ──
deaths = match_stats.get("deaths")
if deaths is not None:
prev_deaths = prev_state.get("deaths")
new_state["deaths"] = deaths
if prev_deaths is not None and deaths > prev_deaths:
events.append(
GameEvent(
adapter_id=adapter_id,
event_type="death",
value=1.0,
raw_data={"deaths": deaths, "prev_deaths": prev_deaths},
timestamp=now,
)
)
# ── Trigger: round phase changes ──
round_phase = round_info.get("phase")
prev_round_phase = prev_state.get("round_phase")
new_state["round_phase"] = round_phase
if round_phase and round_phase != prev_round_phase:
if round_phase == "live":
events.append(
GameEvent(
adapter_id=adapter_id,
event_type="round_start",
value=1.0,
raw_data={"round_phase": round_phase},
timestamp=now,
)
)
elif round_phase == "over":
events.append(
GameEvent(
adapter_id=adapter_id,
event_type="round_end",
value=1.0,
raw_data={"round_phase": round_phase},
timestamp=now,
)
)
# ── Trigger: bomb state ──
bomb = round_info.get("bomb")
prev_bomb = prev_state.get("bomb")
new_state["bomb"] = bomb
if bomb and bomb != prev_bomb:
if bomb == "planted":
events.append(
GameEvent(
adapter_id=adapter_id,
event_type="objective_captured",
value=1.0,
raw_data={"bomb": bomb},
timestamp=now,
)
)
elif bomb == "defused":
events.append(
GameEvent(
adapter_id=adapter_id,
event_type="objective_lost",
value=1.0,
raw_data={"bomb": bomb},
timestamp=now,
)
)
# ── Trigger: flashbang ──
flashed = player_state.get("flashed")
if flashed is not None and flashed > 0:
prev_flashed = prev_state.get("flashed", 0)
if prev_flashed == 0:
events.append(
GameEvent(
adapter_id=adapter_id,
event_type="blinded",
value=max(0.0, min(1.0, float(flashed) / 255.0)),
raw_data={"flashed": flashed},
timestamp=now,
)
)
new_state["flashed"] = flashed if flashed is not None else 0
# ── Team affiliation ──
team = player.get("team")
if team:
new_state["team"] = team
if team == "CT":
events.append(
GameEvent(
adapter_id=adapter_id,
event_type="team_a",
value=1.0,
raw_data={"team": team},
timestamp=now,
)
)
elif team == "T":
events.append(
GameEvent(
adapter_id=adapter_id,
event_type="team_b",
value=1.0,
raw_data={"team": team},
timestamp=now,
)
)
return events, new_state
@classmethod
def validate_auth(
cls,
headers: dict[str, str],
payload: dict[str, Any],
adapter_config: dict[str, Any],
) -> bool:
"""Validate CS2 GSI auth token from payload["auth"]["token"]."""
expected_token = adapter_config.get("auth_token")
if not expected_token:
# No token configured — accept all
return True
auth_section = payload.get("auth", {})
actual_token = auth_section.get("token", "")
return bool(actual_token and actual_token == expected_token)
@classmethod
def get_config_schema(cls) -> dict[str, Any]:
"""Return CS2 adapter config schema."""
return {
"type": "object",
"properties": {
"auth_token": {
"type": "string",
"title": "Auth Token",
"description": (
"The token string you set in your CS2 GSI config file "
"(gamestate_integration_*.cfg). Must match exactly."
),
},
},
}
@classmethod
def get_setup_instructions(cls) -> str:
"""Return CS2 GSI setup instructions."""
return (
"## CS2 Game State Integration Setup\n\n"
"1. Navigate to your CS2 config folder:\n"
" `Steam/steamapps/common/Counter-Strike Global Offensive/game/csgo/cfg/`\n\n"
"2. Create a file named `gamestate_integration_wled.cfg` with:\n"
" ```\n"
' "WLED Screen Controller"\n'
" {\n"
' "uri" "http://<YOUR_IP>:8080/api/v1/game-integrations/<ID>/event"\n'
' "timeout" "5.0"\n'
' "buffer" "0.1"\n'
' "throttle" "0.1"\n'
' "heartbeat" "30.0"\n'
' "auth"\n'
" {\n"
' "token" "<YOUR_TOKEN>"\n'
" }\n"
' "data"\n'
" {\n"
' "player_id" "1"\n'
' "player_state" "1"\n'
' "player_weapons" "1"\n'
' "player_match_stats" "1"\n'
' "round" "1"\n'
' "map" "1"\n'
" }\n"
" }\n"
" ```\n\n"
"3. Set the same token in the adapter config above.\n\n"
"4. Launch CS2 — events will start flowing automatically.\n"
)
@classmethod
def supports_auto_setup(cls) -> bool:
"""CS2 supports automatic GSI config file generation."""
return True
@classmethod
def auto_setup(
cls,
integration_id: str,
adapter_config: dict[str, Any],
server_url: str,
) -> dict[str, Any]:
"""Write a CS2 GSI config file automatically.
Generates an auth token if none is configured. Writes the config
to the CS2 cfg directory.
"""
from wled_controller.core.game_integration.steam_finder import find_game_cfg_path
cfg_path = find_game_cfg_path("cs2")
if not cfg_path:
return {
"success": False,
"file_path": "",
"message": "CS2 installation not found. Is Steam/CS2 installed?",
"token_generated": False,
"adapter_config": adapter_config,
}
# Generate auth token if not set
token_generated = False
config = dict(adapter_config)
auth_token = config.get("auth_token", "")
if not auth_token:
auth_token = secrets.token_hex(16)
config["auth_token"] = auth_token
token_generated = True
uri = f"{server_url}/api/v1/game-integrations/{integration_id}/event"
cfg_content = (
'"WLED Screen Controller"\n'
"{\n"
f' "uri" "{uri}"\n'
' "timeout" "5.0"\n'
' "buffer" "0.1"\n'
' "throttle" "0.1"\n'
' "heartbeat" "30.0"\n'
' "auth"\n'
" {\n"
f' "token" "{auth_token}"\n'
" }\n"
' "data"\n'
" {\n"
' "player_id" "1"\n'
' "player_state" "1"\n'
' "player_weapons" "1"\n'
' "player_match_stats" "1"\n'
' "round" "1"\n'
' "map" "1"\n'
" }\n"
"}\n"
)
file_path = cfg_path / "gamestate_integration_wled.cfg"
try:
file_path.write_text(cfg_content, encoding="utf-8")
logger.info("Wrote CS2 GSI config to %s", file_path)
except OSError as e:
return {
"success": False,
"file_path": str(file_path),
"message": f"Failed to write config file: {e}",
"token_generated": False,
"adapter_config": adapter_config,
}
return {
"success": True,
"file_path": str(file_path),
"message": "CS2 GSI config written successfully. Restart CS2 to apply.",
"token_generated": token_generated,
"adapter_config": config,
}
def _get_active_weapon(weapons: dict[str, Any]) -> dict[str, Any] | None:
"""Find the currently active weapon from the CS2 weapons dict."""
for weapon in weapons.values():
if isinstance(weapon, dict) and weapon.get("state") == "active":
return weapon
return None
@@ -0,0 +1,330 @@
"""Dota 2 Game State Integration adapter.
Parses Dota 2 GSI JSON payloads into standardized GameEvents. Dota 2's GSI
format is similar to CS2 but with different payload structure focused on
hero state, match info, and gold.
Ref: https://developer.valvesoftware.com/wiki/Dota_2_Game_State_Integration
"""
import secrets
import time
from typing import Any, ClassVar
from wled_controller.core.game_integration.base_adapter import GameAdapter
from wled_controller.core.game_integration.events import GameEvent
from wled_controller.utils import get_logger
logger = get_logger(__name__)
class Dota2Adapter(GameAdapter):
"""Adapter for Dota 2 Game State Integration."""
ADAPTER_TYPE: ClassVar[str] = "dota2"
DISPLAY_NAME: ClassVar[str] = "Dota 2 GSI"
GAME_NAME: ClassVar[str] = "Dota 2"
SUPPORTED_EVENTS: ClassVar[list[str]] = [
"health",
"mana",
"kill",
"death",
"match_start",
"match_end",
"gold",
]
@classmethod
def parse_payload(
cls,
payload: dict[str, Any],
adapter_config: dict[str, Any],
prev_state: dict[str, Any],
) -> tuple[list[GameEvent], dict[str, Any]]:
"""Parse a Dota 2 GSI payload into standardized events."""
events: list[GameEvent] = []
new_state = dict(prev_state)
adapter_id = adapter_config.get("adapter_id", "dota2")
now = time.monotonic()
hero = payload.get("hero", {})
player = payload.get("player", {})
game_map = payload.get("map", {})
# ── Continuous: health ──
hp = hero.get("health")
max_hp = hero.get("max_health")
if hp is not None and max_hp and max_hp > 0:
events.append(
GameEvent(
adapter_id=adapter_id,
event_type="health",
value=max(0.0, min(1.0, float(hp) / float(max_hp))),
raw_data={"health": hp, "max_health": max_hp},
timestamp=now,
)
)
# ── Continuous: mana ──
mp = hero.get("mana")
max_mp = hero.get("max_mana")
if mp is not None and max_mp and max_mp > 0:
events.append(
GameEvent(
adapter_id=adapter_id,
event_type="mana",
value=max(0.0, min(1.0, float(mp) / float(max_mp))),
raw_data={"mana": mp, "max_mana": max_mp},
timestamp=now,
)
)
# ── Continuous: gold ──
gold = player.get("gold")
if gold is not None:
# Normalize to 0-1 with a reasonable max (99999 net worth)
max_gold = float(adapter_config.get("max_gold", 99999))
events.append(
GameEvent(
adapter_id=adapter_id,
event_type="gold",
value=max(0.0, min(1.0, float(gold) / max_gold)),
raw_data={"gold": gold},
timestamp=now,
)
)
# ── Trigger: kills (diff-based) ──
kills = player.get("kills")
if kills is not None:
prev_kills = prev_state.get("kills")
new_state["kills"] = kills
if prev_kills is not None and kills > prev_kills:
for _ in range(kills - prev_kills):
events.append(
GameEvent(
adapter_id=adapter_id,
event_type="kill",
value=1.0,
raw_data={"kills": kills, "prev_kills": prev_kills},
timestamp=now,
)
)
# ── Trigger: deaths (diff-based) ──
deaths = player.get("deaths")
if deaths is not None:
prev_deaths = prev_state.get("deaths")
new_state["deaths"] = deaths
if prev_deaths is not None and deaths > prev_deaths:
events.append(
GameEvent(
adapter_id=adapter_id,
event_type="death",
value=1.0,
raw_data={"deaths": deaths, "prev_deaths": prev_deaths},
timestamp=now,
)
)
# ── Trigger: match phase ──
game_state = game_map.get("game_state")
prev_game_state = prev_state.get("game_state")
new_state["game_state"] = game_state
if game_state and game_state != prev_game_state:
# Dota 2 states: DOTA_GAMERULES_STATE_*
if game_state in (
"DOTA_GAMERULES_STATE_PRE_GAME",
"DOTA_GAMERULES_STATE_GAME_IN_PROGRESS",
):
events.append(
GameEvent(
adapter_id=adapter_id,
event_type="match_start",
value=1.0,
raw_data={"game_state": game_state},
timestamp=now,
)
)
elif game_state in (
"DOTA_GAMERULES_STATE_POST_GAME",
"DOTA_GAMERULES_STATE_DISCONNECT",
):
events.append(
GameEvent(
adapter_id=adapter_id,
event_type="match_end",
value=1.0,
raw_data={"game_state": game_state},
timestamp=now,
)
)
return events, new_state
@classmethod
def validate_auth(
cls,
headers: dict[str, str],
payload: dict[str, Any],
adapter_config: dict[str, Any],
) -> bool:
"""Validate Dota 2 GSI auth token from payload["auth"]["token"]."""
expected_token = adapter_config.get("auth_token")
if not expected_token:
return True
auth_section = payload.get("auth", {})
actual_token = auth_section.get("token", "")
return bool(actual_token and actual_token == expected_token)
@classmethod
def get_config_schema(cls) -> dict[str, Any]:
"""Return Dota 2 adapter config schema."""
return {
"type": "object",
"properties": {
"auth_token": {
"type": "string",
"title": "Auth Token",
"description": (
"The token string from your Dota 2 GSI config file. "
"Must match the token in gamestate_integration_*.cfg."
),
},
"max_gold": {
"type": "number",
"title": "Max Gold",
"description": "Maximum gold value for normalization (default: 99999).",
"default": 99999,
},
},
}
@classmethod
def get_setup_instructions(cls) -> str:
"""Return Dota 2 GSI setup instructions."""
return (
"## Dota 2 Game State Integration Setup\n\n"
"1. Navigate to your Dota 2 config folder:\n"
" `Steam/steamapps/common/dota 2 beta/game/dota/cfg/gamestate_integration/`\n\n"
"2. Create a file named `gamestate_integration_wled.cfg` with:\n"
" ```\n"
' "WLED Screen Controller"\n'
" {\n"
' "uri" "http://<YOUR_IP>:8080/api/v1/game-integrations/<ID>/event"\n'
' "timeout" "5.0"\n'
' "buffer" "0.1"\n'
' "throttle" "0.1"\n'
' "heartbeat" "30.0"\n'
' "auth"\n'
" {\n"
' "token" "<YOUR_TOKEN>"\n'
" }\n"
' "data"\n'
" {\n"
' "hero" "1"\n'
' "player" "1"\n'
' "map" "1"\n'
" }\n"
" }\n"
" ```\n\n"
"3. Set the same token in the adapter config above.\n\n"
"4. Launch Dota 2 and enter a match — events will start flowing.\n"
)
@classmethod
def supports_auto_setup(cls) -> bool:
"""Dota 2 supports automatic GSI config file generation."""
return True
@classmethod
def auto_setup(
cls,
integration_id: str,
adapter_config: dict[str, Any],
server_url: str,
) -> dict[str, Any]:
"""Write a Dota 2 GSI config file automatically.
Generates an auth token if none is configured. Writes the config
to the Dota 2 gamestate_integration subdirectory (created if needed).
"""
from wled_controller.core.game_integration.steam_finder import find_game_cfg_path
cfg_path = find_game_cfg_path("dota2")
if not cfg_path:
return {
"success": False,
"file_path": "",
"message": "Dota 2 installation not found. Is Steam/Dota 2 installed?",
"token_generated": False,
"adapter_config": adapter_config,
}
# Dota 2 GSI configs live in a gamestate_integration/ subdirectory
gsi_dir = cfg_path / "gamestate_integration"
try:
gsi_dir.mkdir(parents=False, exist_ok=True)
except OSError as e:
return {
"success": False,
"file_path": str(gsi_dir),
"message": f"Failed to create gamestate_integration directory: {e}",
"token_generated": False,
"adapter_config": adapter_config,
}
# Generate auth token if not set
token_generated = False
config = dict(adapter_config)
auth_token = config.get("auth_token", "")
if not auth_token:
auth_token = secrets.token_hex(16)
config["auth_token"] = auth_token
token_generated = True
uri = f"{server_url}/api/v1/game-integrations/{integration_id}/event"
cfg_content = (
'"WLED Screen Controller"\n'
"{\n"
f' "uri" "{uri}"\n'
' "timeout" "5.0"\n'
' "buffer" "0.1"\n'
' "throttle" "0.1"\n'
' "heartbeat" "30.0"\n'
' "auth"\n'
" {\n"
f' "token" "{auth_token}"\n'
" }\n"
' "data"\n'
" {\n"
' "hero" "1"\n'
' "player" "1"\n'
' "map" "1"\n'
" }\n"
"}\n"
)
file_path = gsi_dir / "gamestate_integration_wled.cfg"
try:
file_path.write_text(cfg_content, encoding="utf-8")
logger.info("Wrote Dota 2 GSI config to %s", file_path)
except OSError as e:
return {
"success": False,
"file_path": str(file_path),
"message": f"Failed to write config file: {e}",
"token_generated": False,
"adapter_config": adapter_config,
}
return {
"success": True,
"file_path": str(file_path),
"message": "Dota 2 GSI config written successfully. Restart Dota 2 to apply.",
"token_generated": token_generated,
"adapter_config": config,
}
@@ -0,0 +1,172 @@
"""Generic webhook adapter with user-defined JSON path mappings.
Allows users to define custom JSON path mappings via the adapter_config
rather than a YAML file. Delegates all parsing logic to MappingAdapter.
"""
from typing import Any, ClassVar
from wled_controller.core.game_integration.base_adapter import GameAdapter
from wled_controller.core.game_integration.events import GameEvent
from wled_controller.core.game_integration.mapping_adapter import MappingAdapter
from wled_controller.utils import get_logger
logger = get_logger(__name__)
class GenericWebhookAdapter(GameAdapter):
"""Generic webhook adapter with user-defined JSON path mappings.
Users configure mappings in the adapter_config field of their
game integration config. The mappings follow the same format as
MappingAdapter YAML files.
"""
ADAPTER_TYPE: ClassVar[str] = "generic_webhook"
DISPLAY_NAME: ClassVar[str] = "Generic Webhook"
GAME_NAME: ClassVar[str] = "Any Game"
SUPPORTED_EVENTS: ClassVar[list[str]] = [] # Dynamic based on mappings
@classmethod
def parse_payload(
cls,
payload: dict[str, Any],
adapter_config: dict[str, Any],
prev_state: dict[str, Any],
) -> tuple[list[GameEvent], dict[str, Any]]:
"""Parse a webhook payload using user-defined mappings.
Delegates to a MappingAdapter instance built from adapter_config["mappings"].
"""
mappings = adapter_config.get("mappings", [])
if not mappings:
return [], dict(prev_state)
# Build a transient MappingAdapter from the config
mapping_adapter = _build_mapping_adapter(adapter_config)
return mapping_adapter.parse_payload(payload, adapter_config, prev_state)
@classmethod
def validate_auth(
cls,
headers: dict[str, str],
payload: dict[str, Any],
adapter_config: dict[str, Any],
) -> bool:
"""Validate auth using a configurable header token."""
expected_token = adapter_config.get("auth_token")
if not expected_token:
# No auth configured
return True
auth_header = adapter_config.get("auth_header", "Authorization")
actual_value = headers.get(auth_header, "")
# Support "Bearer <token>" format
if actual_value.startswith("Bearer "):
actual_value = actual_value[7:]
return bool(actual_value and actual_value == expected_token)
@classmethod
def get_config_schema(cls) -> dict[str, Any]:
"""Return generic webhook config schema."""
return {
"type": "object",
"properties": {
"auth_token": {
"type": "string",
"title": "Auth Token",
"description": "Optional token for authenticating incoming webhooks.",
},
"auth_header": {
"type": "string",
"title": "Auth Header",
"description": (
"HTTP header to check for auth token "
"(default: Authorization). Supports Bearer prefix."
),
"default": "Authorization",
},
"mappings": {
"type": "array",
"title": "Event Mappings",
"description": "List of JSON path to event type mappings.",
"items": {
"type": "object",
"properties": {
"source_path": {
"type": "string",
"description": "Dot-notation path in JSON payload (e.g. player.health)",
},
"event": {
"type": "string",
"description": "Standard event type (e.g. health, kill, death)",
},
"min": {
"type": "number",
"description": "Minimum raw value for normalization (default: 0)",
"default": 0,
},
"max": {
"type": "number",
"description": "Maximum raw value for normalization (default: 100)",
"default": 100,
},
"trigger": {
"type": "string",
"description": "Trigger mode: on_change, on_increase, on_decrease, on_value",
"default": "on_change",
"enum": ["on_change", "on_increase", "on_decrease", "on_value"],
},
},
"required": ["source_path", "event"],
},
},
},
}
@classmethod
def get_setup_instructions(cls) -> str:
"""Return generic webhook setup instructions."""
return (
"## Generic Webhook Setup\n\n"
"Use this adapter to connect any game or application that can send "
"HTTP POST requests with JSON payloads.\n\n"
"**Steps:**\n"
"1. Configure your event mappings above — map JSON paths to standard events\n"
"2. Set an auth token (optional but recommended)\n"
"3. Point your game/application to:\n"
" `POST http://<YOUR_IP>:8080/api/v1/game-integrations/<ID>/event`\n\n"
"**Mapping example:**\n"
"- Source path: `player.stats.health` → Event: `health` (min: 0, max: 100)\n"
"- Source path: `events.kill_count` → Event: `kill` (trigger: on_increase)\n\n"
"**Auth:**\n"
"- Set `Authorization: Bearer <token>` header in your webhook sender\n"
"- Or configure a custom auth header name in the adapter config\n"
)
def _build_mapping_adapter(adapter_config: dict[str, Any]) -> MappingAdapter:
"""Build a MappingAdapter instance from adapter_config."""
mappings = adapter_config.get("mappings", [])
name = adapter_config.get("adapter_id", "generic_webhook")
game = adapter_config.get("game_name", "Custom Game")
auth: dict[str, Any] = {}
if adapter_config.get("auth_token"):
auth = {
"type": "header",
"header": adapter_config.get("auth_header", "Authorization"),
}
return MappingAdapter(
{
"name": name,
"game": game,
"protocol": "webhook",
"mappings": mappings,
"auth": auth,
}
)
@@ -0,0 +1,285 @@
"""League of Legends Live Client Data API adapter.
Poll-based adapter that fetches game data from the LoL client's local
HTTP API at https://127.0.0.1:2999/liveclientdata/allgamedata.
Unlike GSI adapters (CS2, Dota 2), this adapter manages its own polling
thread. The thread is started/stopped via start_polling()/stop_polling().
Ref: https://developer.riotgames.com/docs/lol#game-client-api
"""
import threading
import time
from typing import Any, ClassVar
from wled_controller.core.game_integration.base_adapter import GameAdapter
from wled_controller.core.game_integration.events import GameEvent
from wled_controller.utils import get_logger
logger = get_logger(__name__)
# LoL Live Client Data API base URL (local, self-signed SSL)
LOL_API_BASE = "https://127.0.0.1:2999/liveclientdata"
class LoLAdapter(GameAdapter):
"""Adapter for League of Legends Live Client Data API."""
ADAPTER_TYPE: ClassVar[str] = "lol"
DISPLAY_NAME: ClassVar[str] = "League of Legends"
GAME_NAME: ClassVar[str] = "League of Legends"
SUPPORTED_EVENTS: ClassVar[list[str]] = [
"health",
"mana",
"gold",
"death",
"objective_progress", # respawn mapped to objective_progress
"speed", # player level mapped to speed (continuous 0-18)
]
@classmethod
def parse_payload(
cls,
payload: dict[str, Any],
adapter_config: dict[str, Any],
prev_state: dict[str, Any],
) -> tuple[list[GameEvent], dict[str, Any]]:
"""Parse a LoL Live Client Data payload into standardized events.
The payload is the full allgamedata response containing
activePlayer, allPlayers, gameData, and events sections.
"""
events: list[GameEvent] = []
new_state = dict(prev_state)
adapter_id = adapter_config.get("adapter_id", "lol")
now = time.monotonic()
active_player = payload.get("activePlayer", {})
champion_stats = active_player.get("championStats", {})
summoner_name = active_player.get("summonerName", "")
# ── Continuous: health ──
hp = champion_stats.get("currentHealth")
max_hp = champion_stats.get("maxHealth")
if hp is not None and max_hp and max_hp > 0:
events.append(
GameEvent(
adapter_id=adapter_id,
event_type="health",
value=max(0.0, min(1.0, float(hp) / float(max_hp))),
raw_data={"health": hp, "max_health": max_hp},
timestamp=now,
)
)
# ── Continuous: mana (resourceValue) ──
resource_val = champion_stats.get("resourceValue")
resource_max = champion_stats.get("resourceMax")
if resource_val is not None and resource_max and resource_max > 0:
events.append(
GameEvent(
adapter_id=adapter_id,
event_type="mana",
value=max(0.0, min(1.0, float(resource_val) / float(resource_max))),
raw_data={"mana": resource_val, "max_mana": resource_max},
timestamp=now,
)
)
# ── Continuous: level (mapped to speed, normalized 0-18) ──
level = active_player.get("level")
if level is not None:
events.append(
GameEvent(
adapter_id=adapter_id,
event_type="speed",
value=max(0.0, min(1.0, float(level) / 18.0)),
raw_data={"level": level},
timestamp=now,
)
)
# ── Continuous: gold ──
# Gold is available per player in allPlayers array
all_players = payload.get("allPlayers", [])
player_data = _find_player_by_name(all_players, summoner_name)
if player_data:
gold = player_data.get("currentGold")
if gold is not None:
max_gold = float(adapter_config.get("max_gold", 30000))
events.append(
GameEvent(
adapter_id=adapter_id,
event_type="gold",
value=max(0.0, min(1.0, float(gold) / max_gold)),
raw_data={"gold": gold},
timestamp=now,
)
)
# ── Trigger: death detection ──
# If health drops to 0, player is dead
if hp is not None and float(hp) <= 0:
was_alive = prev_state.get("alive", True)
if was_alive:
events.append(
GameEvent(
adapter_id=adapter_id,
event_type="death",
value=1.0,
raw_data={"health": hp},
timestamp=now,
)
)
new_state["alive"] = False
elif hp is not None and float(hp) > 0:
was_alive = prev_state.get("alive", True)
if not was_alive:
# Respawn detected
events.append(
GameEvent(
adapter_id=adapter_id,
event_type="objective_progress",
value=1.0,
raw_data={"respawn": True, "health": hp},
timestamp=now,
)
)
new_state["alive"] = True
return events, new_state
@classmethod
def validate_auth(
cls,
headers: dict[str, str],
payload: dict[str, Any],
adapter_config: dict[str, Any],
) -> bool:
"""LoL Live Client API is local-only — no auth needed."""
return True
@classmethod
def get_config_schema(cls) -> dict[str, Any]:
"""Return LoL adapter config schema."""
return {
"type": "object",
"properties": {
"poll_interval_ms": {
"type": "integer",
"title": "Poll Interval (ms)",
"description": "How often to poll the LoL client API (default: 500ms).",
"default": 500,
"minimum": 100,
"maximum": 5000,
},
"max_gold": {
"type": "number",
"title": "Max Gold",
"description": "Maximum gold for normalization (default: 30000).",
"default": 30000,
},
},
}
@classmethod
def get_setup_instructions(cls) -> str:
"""Return LoL setup instructions."""
return (
"## League of Legends Live Client Data Setup\n\n"
"The LoL Live Client Data API runs automatically when you're in a game.\n\n"
"**Requirements:**\n"
"- WLED Screen Controller must run on the same machine as LoL\n"
"- The LoL client exposes data at `https://127.0.0.1:2999`\n"
"- No configuration needed in the LoL client\n\n"
"**How it works:**\n"
"- The adapter polls the local API at the configured interval\n"
"- Data is only available while you're in an active game\n"
"- The API uses a self-signed SSL certificate (handled automatically)\n\n"
"**Note:** This adapter uses polling mode. Enable the integration, "
"then start a game — events will appear automatically.\n"
)
class LoLPoller:
"""Polling thread manager for the LoL Live Client Data API.
Creates a daemon thread that periodically fetches game data from the
local LoL client API and passes it through the adapter.
"""
def __init__(
self,
adapter_config: dict[str, Any],
callback: Any, # Callable[[dict], None]
) -> None:
self._adapter_config = adapter_config
self._callback = callback
self._stop_event = threading.Event()
self._thread: threading.Thread | None = None
self._poll_interval = adapter_config.get("poll_interval_ms", 500) / 1000.0
def start(self) -> None:
"""Start the polling thread."""
if self._thread and self._thread.is_alive():
logger.warning("LoL poller already running")
return
self._stop_event.clear()
self._thread = threading.Thread(
target=self._poll_loop,
name="lol-poller",
daemon=True,
)
self._thread.start()
logger.info("LoL poller started (interval: %.1fs)", self._poll_interval)
def stop(self) -> None:
"""Stop the polling thread."""
self._stop_event.set()
if self._thread:
self._thread.join(timeout=5.0)
self._thread = None
logger.info("LoL poller stopped")
@property
def is_running(self) -> bool:
"""Check if the polling thread is alive."""
return self._thread is not None and self._thread.is_alive()
def _poll_loop(self) -> None:
"""Main polling loop — runs in a separate daemon thread."""
import urllib.request
import ssl
import json
# LoL uses self-signed cert — skip verification
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
url = f"{LOL_API_BASE}/allgamedata"
while not self._stop_event.is_set():
try:
req = urllib.request.Request(url)
with urllib.request.urlopen(req, timeout=2, context=ctx) as resp:
data = json.loads(resp.read().decode("utf-8"))
self._callback(data)
except Exception:
# Game not running or API not available — silently retry
pass
self._stop_event.wait(self._poll_interval)
def _find_player_by_name(
all_players: list[dict[str, Any]],
summoner_name: str,
) -> dict[str, Any] | None:
"""Find the active player's data in the allPlayers array."""
for player in all_players:
if player.get("summonerName") == summoner_name:
return player
return None
@@ -0,0 +1,109 @@
"""Abstract base class for game adapters.
Every game adapter (built-in, community YAML, or generic webhook) implements
this interface. The adapter is responsible for:
- Parsing a raw JSON payload into standardized GameEvent instances
- Validating authentication (webhook secret, etc.)
- Describing its configuration schema for UI auto-generation
- Providing setup instructions for the game
"""
from abc import ABC, abstractmethod
from typing import Any, ClassVar
from wled_controller.core.game_integration.events import GameEvent
class GameAdapter(ABC):
"""Base class for all game integration adapters."""
ADAPTER_TYPE: ClassVar[str] = "base"
DISPLAY_NAME: ClassVar[str] = "Base Adapter"
GAME_NAME: ClassVar[str] = "Unknown"
SUPPORTED_EVENTS: ClassVar[list[str]] = []
@classmethod
@abstractmethod
def parse_payload(
cls,
payload: dict[str, Any],
adapter_config: dict[str, Any],
prev_state: dict[str, Any],
) -> tuple[list[GameEvent], dict[str, Any]]:
"""Parse a game payload into standardized events.
Args:
payload: Raw JSON payload from the game.
adapter_config: Adapter-specific configuration (secrets, mappings).
prev_state: Previous adapter state for diff-based trigger detection.
Returns:
Tuple of (list of GameEvent instances, updated state dict).
"""
@classmethod
@abstractmethod
def validate_auth(
cls,
headers: dict[str, str],
payload: dict[str, Any],
adapter_config: dict[str, Any],
) -> bool:
"""Validate that the incoming request is authentic.
Args:
headers: HTTP request headers.
payload: Raw JSON payload.
adapter_config: Adapter-specific configuration (may contain secrets).
Returns:
True if the request is authenticated, False otherwise.
"""
@classmethod
def get_config_schema(cls) -> dict[str, Any]:
"""Return a JSON Schema describing adapter-specific config fields.
Override in subclasses to expose configuration options in the UI.
Default implementation returns an empty schema.
"""
return {"type": "object", "properties": {}}
@classmethod
def get_setup_instructions(cls) -> str:
"""Return Markdown setup instructions for this game adapter.
Override in subclasses to provide game-specific setup guidance.
"""
return f"No setup instructions available for {cls.DISPLAY_NAME}."
@classmethod
def supports_auto_setup(cls) -> bool:
"""Whether this adapter supports automatic GSI config file setup.
Override in subclasses that can write game config files.
"""
return False
@classmethod
def auto_setup(
cls,
integration_id: str,
adapter_config: dict[str, Any],
server_url: str,
) -> dict[str, Any]:
"""Automatically write game config files for this adapter.
Args:
integration_id: The integration ID (used in the callback URL).
adapter_config: Current adapter configuration (may be updated).
server_url: Base URL of the WLED controller server.
Returns:
Dict with keys: success (bool), file_path (str), message (str),
token_generated (bool), adapter_config (dict, possibly updated).
Raises:
NotImplementedError: If auto-setup is not supported.
"""
raise NotImplementedError(f"{cls.DISPLAY_NAME} does not support auto setup.")
@@ -0,0 +1,140 @@
"""Community adapter loader — scan data/game_adapters/ for YAML adapter files.
Loads all .yaml/.yml files from the community adapter directory and makes
them available for selection when creating game integrations.
"""
from pathlib import Path
from typing import Any
from wled_controller.core.game_integration.mapping_adapter import (
MappingAdapter,
load_adapter_from_yaml,
)
from wled_controller.utils import get_logger
logger = get_logger(__name__)
# Default directory for community adapter YAML files
_DEFAULT_ADAPTER_DIR = Path(__file__).parent.parent.parent / "data" / "game_adapters"
# Registry of loaded community adapters (adapter_type -> MappingAdapter instance)
_community_adapters: dict[str, MappingAdapter] = {}
def get_community_adapter_dir(custom_dir: str | Path | None = None) -> Path:
"""Return the community adapter directory path.
Args:
custom_dir: Optional override path. Falls back to built-in data/game_adapters/.
Returns:
Path to the adapter directory.
"""
if custom_dir:
return Path(custom_dir)
return _DEFAULT_ADAPTER_DIR
def load_community_adapters(
adapter_dir: str | Path | None = None,
) -> dict[str, MappingAdapter]:
"""Scan a directory for YAML adapter files and load them.
Args:
adapter_dir: Directory to scan. Defaults to data/game_adapters/.
Returns:
Dict mapping adapter type keys to MappingAdapter instances.
"""
directory = get_community_adapter_dir(adapter_dir)
if not directory.exists():
logger.info("Community adapter directory not found: %s", directory)
return {}
loaded: dict[str, MappingAdapter] = {}
yaml_files = sorted(directory.glob("*.yaml")) + sorted(directory.glob("*.yml"))
for yaml_path in yaml_files:
try:
adapter = load_adapter_from_yaml(yaml_path)
# Use filename stem as the adapter key
adapter_key = f"community_{yaml_path.stem}"
loaded[adapter_key] = adapter
logger.info(
"Loaded community adapter '%s' (%s) from %s",
adapter.name,
adapter.game,
yaml_path.name,
)
except (ValueError, FileNotFoundError) as exc:
logger.warning("Failed to load community adapter %s: %s", yaml_path.name, exc)
return loaded
def register_community_adapters(
adapter_dir: str | Path | None = None,
) -> int:
"""Load community adapters and store them in the module-level registry.
Args:
adapter_dir: Directory to scan. Defaults to data/game_adapters/.
Returns:
Number of adapters successfully loaded.
"""
global _community_adapters
_community_adapters = load_community_adapters(adapter_dir)
count = len(_community_adapters)
if count > 0:
logger.info("Registered %d community adapter(s)", count)
return count
def get_community_adapters() -> dict[str, MappingAdapter]:
"""Return all loaded community adapters.
Returns:
Dict mapping adapter keys to MappingAdapter instances.
"""
return dict(_community_adapters)
def get_community_adapter(adapter_key: str) -> MappingAdapter | None:
"""Look up a community adapter by its key.
Args:
adapter_key: The adapter key (e.g. 'community_minecraft').
Returns:
The MappingAdapter instance, or None if not found.
"""
return _community_adapters.get(adapter_key)
def get_community_adapter_info() -> list[dict[str, Any]]:
"""Return metadata for all loaded community adapters.
Returns:
List of dicts with adapter_type, display_name, game_name, supported_events.
"""
result = []
for adapter_key, adapter in _community_adapters.items():
result.append(
{
"adapter_type": adapter_key,
"display_name": adapter.name,
"game_name": adapter.game,
"supported_events": adapter.supported_events,
"source": "community",
}
)
return result
def clear_community_adapters() -> None:
"""Clear all loaded community adapters (for testing)."""
global _community_adapters
_community_adapters = {}
@@ -0,0 +1,138 @@
"""Thread-safe game event pub/sub bus.
The GameEventBus dispatches GameEvent instances to subscribers. Supports
type-specific subscriptions (receive only events of a given type) and
wildcard subscriptions (receive all events, useful for diagnostics).
"""
import collections
import threading
import uuid
from typing import Callable
from wled_controller.core.game_integration.events import GameEvent
from wled_controller.utils import get_logger
logger = get_logger(__name__)
Callback = Callable[[GameEvent], None]
class GameEventBus:
"""In-process pub/sub bus for game events.
Thread-safe: publish() and subscribe/unsubscribe can be called from
any thread concurrently.
"""
def __init__(self, recent_maxlen: int = 100) -> None:
self._lock = threading.Lock()
# event_type -> {sub_id: callback}
self._subscribers: dict[str, dict[str, Callback]] = {}
# Wildcard subscribers receive every event
self._wildcard_subscribers: dict[str, Callback] = {}
# Recent events ring buffer
self._recent_events: collections.deque[GameEvent] = collections.deque(
maxlen=recent_maxlen,
)
# Stats: event_type -> count
self._event_counts: dict[str, int] = {}
self._last_event_timestamp: float | None = None
def publish(self, event: GameEvent) -> None:
"""Dispatch an event to all matching subscribers.
Callbacks are invoked synchronously under the lock released —
a snapshot of subscribers is taken while holding the lock, then
callbacks are called outside the lock to avoid deadlocks.
"""
with self._lock:
self._recent_events.append(event)
self._event_counts[event.event_type] = self._event_counts.get(event.event_type, 0) + 1
self._last_event_timestamp = event.timestamp
# Snapshot subscribers for this event type + wildcards
type_subs = dict(self._subscribers.get(event.event_type, {}))
wildcard_subs = dict(self._wildcard_subscribers)
# Invoke outside lock
for sub_id, callback in type_subs.items():
try:
callback(event)
except Exception:
logger.exception(f"Error in event subscriber {sub_id}")
for sub_id, callback in wildcard_subs.items():
try:
callback(event)
except Exception:
logger.exception(f"Error in wildcard subscriber {sub_id}")
def subscribe(self, event_type: str, callback: Callback) -> str:
"""Subscribe to events of a specific type.
Returns:
A subscription ID that can be passed to unsubscribe().
"""
sub_id = f"sub_{uuid.uuid4().hex[:8]}"
with self._lock:
if event_type not in self._subscribers:
self._subscribers[event_type] = {}
self._subscribers[event_type][sub_id] = callback
logger.debug(f"Subscribed {sub_id} to event type '{event_type}'")
return sub_id
def subscribe_all(self, callback: Callback) -> str:
"""Subscribe to all events (wildcard).
Returns:
A subscription ID that can be passed to unsubscribe().
"""
sub_id = f"sub_{uuid.uuid4().hex[:8]}"
with self._lock:
self._wildcard_subscribers[sub_id] = callback
logger.debug(f"Subscribed {sub_id} as wildcard listener")
return sub_id
def unsubscribe(self, subscription_id: str) -> bool:
"""Remove a subscription by ID.
Returns:
True if the subscription was found and removed.
"""
with self._lock:
# Check type-specific subscribers
for subs in self._subscribers.values():
if subscription_id in subs:
del subs[subscription_id]
logger.debug(f"Unsubscribed {subscription_id}")
return True
# Check wildcard subscribers
if subscription_id in self._wildcard_subscribers:
del self._wildcard_subscribers[subscription_id]
logger.debug(f"Unsubscribed wildcard {subscription_id}")
return True
return False
def get_recent_events(self, limit: int = 50) -> list[GameEvent]:
"""Return the most recent events (newest last).
Args:
limit: Maximum number of events to return.
"""
with self._lock:
events = list(self._recent_events)
# Return the last `limit` events
return events[-limit:]
def get_stats(self) -> dict:
"""Return diagnostic statistics.
Returns:
Dict with 'event_counts' (per-type) and 'last_event_timestamp'.
"""
with self._lock:
return {
"event_counts": dict(self._event_counts),
"last_event_timestamp": self._last_event_timestamp,
}
@@ -0,0 +1,159 @@
"""Standardized game event model and event vocabulary.
Defines the GameEvent frozen dataclass and a vocabulary of standard event
types that all game adapters map into. Users configure effects against these
universal categories, so a "health < 30% -> flash red" config works across
any supported game.
"""
import time
from dataclasses import dataclass, field
from enum import Enum
from typing import Any
class EventCategory(str, Enum):
"""Top-level grouping for event types."""
RESOURCE = "resource"
COMBAT = "combat"
MATCH_FLOW = "match_flow"
OBJECTIVE = "objective"
STATUS_EFFECT = "status_effect"
TEAM = "team"
class ValueType(str, Enum):
"""Whether an event carries a continuous value or is a discrete trigger."""
CONTINUOUS = "continuous"
TRIGGER = "trigger"
@dataclass(frozen=True)
class EventTypeMetadata:
"""Metadata describing a standard event type."""
name: str
display_name: str
category: EventCategory
value_type: ValueType
default_min: float = 0.0
default_max: float = 1.0
# ── Standard Event Vocabulary ────────────────────────────────────────────
_VOCABULARY: dict[str, EventTypeMetadata] = {}
def _reg(
name: str,
display_name: str,
category: EventCategory,
value_type: ValueType,
default_min: float = 0.0,
default_max: float = 1.0,
) -> str:
"""Register an event type in the vocabulary and return its name."""
_VOCABULARY[name] = EventTypeMetadata(
name=name,
display_name=display_name,
category=category,
value_type=value_type,
default_min=default_min,
default_max=default_max,
)
return name
# Resource (continuous)
HEALTH = _reg("health", "Health", EventCategory.RESOURCE, ValueType.CONTINUOUS)
ARMOR = _reg("armor", "Armor", EventCategory.RESOURCE, ValueType.CONTINUOUS)
SHIELD = _reg("shield", "Shield", EventCategory.RESOURCE, ValueType.CONTINUOUS)
MANA = _reg("mana", "Mana", EventCategory.RESOURCE, ValueType.CONTINUOUS)
ENERGY = _reg("energy", "Energy", EventCategory.RESOURCE, ValueType.CONTINUOUS)
AMMO = _reg("ammo", "Ammo", EventCategory.RESOURCE, ValueType.CONTINUOUS)
GOLD = _reg("gold", "Gold", EventCategory.RESOURCE, ValueType.CONTINUOUS)
FUEL = _reg("fuel", "Fuel", EventCategory.RESOURCE, ValueType.CONTINUOUS)
SPEED = _reg("speed", "Speed", EventCategory.RESOURCE, ValueType.CONTINUOUS)
# Combat (triggers)
KILL = _reg("kill", "Kill", EventCategory.COMBAT, ValueType.TRIGGER)
DEATH = _reg("death", "Death", EventCategory.COMBAT, ValueType.TRIGGER)
ASSIST = _reg("assist", "Assist", EventCategory.COMBAT, ValueType.TRIGGER)
DAMAGE_TAKEN = _reg("damage_taken", "Damage Taken", EventCategory.COMBAT, ValueType.TRIGGER)
DAMAGE_DEALT = _reg("damage_dealt", "Damage Dealt", EventCategory.COMBAT, ValueType.TRIGGER)
# Match flow (triggers)
MATCH_START = _reg("match_start", "Match Start", EventCategory.MATCH_FLOW, ValueType.TRIGGER)
MATCH_END = _reg("match_end", "Match End", EventCategory.MATCH_FLOW, ValueType.TRIGGER)
ROUND_START = _reg("round_start", "Round Start", EventCategory.MATCH_FLOW, ValueType.TRIGGER)
ROUND_END = _reg("round_end", "Round End", EventCategory.MATCH_FLOW, ValueType.TRIGGER)
# Objective
OBJECTIVE_CAPTURED = _reg(
"objective_captured",
"Objective Captured",
EventCategory.OBJECTIVE,
ValueType.TRIGGER,
)
OBJECTIVE_LOST = _reg(
"objective_lost",
"Objective Lost",
EventCategory.OBJECTIVE,
ValueType.TRIGGER,
)
OBJECTIVE_PROGRESS = _reg(
"objective_progress",
"Objective Progress",
EventCategory.OBJECTIVE,
ValueType.CONTINUOUS,
)
# Status effects (triggers)
STUNNED = _reg("stunned", "Stunned", EventCategory.STATUS_EFFECT, ValueType.TRIGGER)
BLINDED = _reg("blinded", "Blinded", EventCategory.STATUS_EFFECT, ValueType.TRIGGER)
BUFFED = _reg("buffed", "Buffed", EventCategory.STATUS_EFFECT, ValueType.TRIGGER)
DEBUFFED = _reg("debuffed", "Debuffed", EventCategory.STATUS_EFFECT, ValueType.TRIGGER)
# Team affiliation (continuous — e.g. team score)
TEAM_A = _reg("team_a", "Team A", EventCategory.TEAM, ValueType.CONTINUOUS)
TEAM_B = _reg("team_b", "Team B", EventCategory.TEAM, ValueType.CONTINUOUS)
def get_event_vocabulary() -> dict[str, EventTypeMetadata]:
"""Return a copy of the full event vocabulary."""
return dict(_VOCABULARY)
def get_event_metadata(event_type: str) -> EventTypeMetadata | None:
"""Look up metadata for a standard event type."""
return _VOCABULARY.get(event_type)
def is_known_event_type(event_type: str) -> bool:
"""Check whether an event type is in the standard vocabulary."""
return event_type in _VOCABULARY
# ── GameEvent dataclass ──────────────────────────────────────────────────
@dataclass(frozen=True)
class GameEvent:
"""A single game event emitted by an adapter.
Attributes:
adapter_id: Identifier of the adapter that produced this event.
event_type: Standard event type string from the vocabulary.
value: Normalized value in 0.0-1.0 range (1.0 for triggers).
raw_data: Original game-specific data for debugging.
timestamp: Monotonic timestamp (time.monotonic()).
"""
adapter_id: str
event_type: str
value: float = 1.0
raw_data: dict[str, Any] = field(default_factory=dict)
timestamp: float = field(default_factory=time.monotonic)
@@ -0,0 +1,299 @@
"""YAML-driven mapping adapter for community game integrations.
Allows community contributors to define game adapters as YAML files
without writing Python code. A YAML adapter file specifies:
- Adapter metadata (name, game, protocol)
- A list of mappings from JSON paths to standard event types
- Optional auth configuration
The MappingAdapter class is a concrete GameAdapter whose behavior is
entirely driven by the parsed YAML definition.
"""
import time
from pathlib import Path
from typing import Any
import yaml
from wled_controller.core.game_integration.base_adapter import GameAdapter
from wled_controller.core.game_integration.events import (
GameEvent,
is_known_event_type,
)
from wled_controller.utils import get_logger
logger = get_logger(__name__)
# Valid trigger modes for mapping entries
VALID_TRIGGER_MODES = frozenset({"on_change", "on_increase", "on_decrease", "on_value"})
def _resolve_json_path(data: dict[str, Any], path: str) -> Any | None:
"""Resolve a dot-separated JSON path against a nested dict.
Supports simple dot notation: "player.state.health"
Returns None if the path cannot be resolved.
"""
parts = path.split(".")
current: Any = data
for part in parts:
if isinstance(current, dict) and part in current:
current = current[part]
else:
return None
return current
def validate_adapter_yaml(data: dict[str, Any]) -> list[str]:
"""Validate a parsed YAML adapter definition.
Args:
data: Parsed YAML dict.
Returns:
List of validation error strings (empty = valid).
"""
errors: list[str] = []
# Required top-level fields
if not isinstance(data.get("name"), str) or not data["name"].strip():
errors.append("Missing or empty 'name' field")
if not isinstance(data.get("game"), str) or not data["game"].strip():
errors.append("Missing or empty 'game' field")
if data.get("protocol") not in ("webhook", "poll"):
errors.append("'protocol' must be 'webhook' or 'poll'")
# Mappings
mappings = data.get("mappings")
if not isinstance(mappings, list) or len(mappings) == 0:
errors.append("'mappings' must be a non-empty list")
return errors
for i, mapping in enumerate(mappings):
prefix = f"mappings[{i}]"
if not isinstance(mapping, dict):
errors.append(f"{prefix}: must be a dict")
continue
if not isinstance(mapping.get("source_path"), str) or not mapping["source_path"].strip():
errors.append(f"{prefix}: missing or empty 'source_path'")
event = mapping.get("event")
if not isinstance(event, str) or not event.strip():
errors.append(f"{prefix}: missing or empty 'event'")
elif not is_known_event_type(event):
errors.append(f"{prefix}: unknown event type '{event}'")
trigger = mapping.get("trigger", "on_change")
if trigger not in VALID_TRIGGER_MODES:
errors.append(
f"{prefix}: invalid trigger mode '{trigger}', "
f"must be one of {sorted(VALID_TRIGGER_MODES)}"
)
# min/max are optional but must be numeric if present
for field in ("min", "max"):
if field in mapping:
if not isinstance(mapping[field], (int, float)):
errors.append(f"{prefix}: '{field}' must be numeric")
return errors
class MappingAdapter(GameAdapter):
"""A game adapter whose behavior is defined by a YAML mapping file.
Unlike built-in adapters (classmethods on a class), MappingAdapter
instances carry per-adapter state from their YAML definition.
The classmethod interface is implemented by delegating to instance
data stored on dynamically created subclasses.
"""
ADAPTER_TYPE = "mapping"
DISPLAY_NAME = "YAML Mapping Adapter"
GAME_NAME = "Generic"
SUPPORTED_EVENTS: list[str] = []
def __init__(self, definition: dict[str, Any]) -> None:
self._definition = definition
self._name: str = definition["name"]
self._game: str = definition["game"]
self._protocol: str = definition["protocol"]
self._mappings: list[dict[str, Any]] = definition["mappings"]
self._auth: dict[str, Any] = definition.get("auth", {})
self._supported_events = list({m["event"] for m in self._mappings})
@property
def name(self) -> str:
return self._name
@property
def game(self) -> str:
return self._game
@property
def protocol(self) -> str:
return self._protocol
@property
def supported_events(self) -> list[str]:
return list(self._supported_events)
def parse_payload( # type: ignore[override]
self,
payload: dict[str, Any],
adapter_config: dict[str, Any],
prev_state: dict[str, Any],
) -> tuple[list[GameEvent], dict[str, Any]]:
"""Parse a JSON payload using the YAML mapping definitions.
For continuous events, values are normalized to 0.0-1.0 using
the mapping's min/max range. For triggers, diff-based detection
compares against prev_state.
"""
events: list[GameEvent] = []
new_state = dict(prev_state)
adapter_id = adapter_config.get("adapter_id", self._name)
now = time.monotonic()
for mapping in self._mappings:
source_path: str = mapping["source_path"]
event_type: str = mapping["event"]
trigger_mode: str = mapping.get("trigger", "on_change")
range_min: float = float(mapping.get("min", 0))
range_max: float = float(mapping.get("max", 100))
raw_value = _resolve_json_path(payload, source_path)
if raw_value is None:
continue
try:
numeric_value = float(raw_value)
except (TypeError, ValueError):
# Non-numeric: treat presence as a trigger with value 1.0
events.append(
GameEvent(
adapter_id=adapter_id,
event_type=event_type,
value=1.0,
raw_data={"source_path": source_path, "raw": raw_value},
timestamp=now,
)
)
continue
prev_value = prev_state.get(source_path)
new_state[source_path] = numeric_value
# Determine whether to emit based on trigger mode
should_emit = False
if trigger_mode == "on_change":
should_emit = prev_value is None or numeric_value != prev_value
elif trigger_mode == "on_increase":
should_emit = prev_value is not None and numeric_value > prev_value
elif trigger_mode == "on_decrease":
should_emit = prev_value is not None and numeric_value < prev_value
elif trigger_mode == "on_value":
# Always emit when value is present
should_emit = True
if not should_emit:
continue
# Normalize to 0.0-1.0
range_span = range_max - range_min
if range_span > 0:
normalized = max(0.0, min(1.0, (numeric_value - range_min) / range_span))
else:
normalized = 1.0
events.append(
GameEvent(
adapter_id=adapter_id,
event_type=event_type,
value=normalized,
raw_data={"source_path": source_path, "raw": numeric_value},
timestamp=now,
)
)
return events, new_state
def validate_auth( # type: ignore[override]
self,
headers: dict[str, str],
payload: dict[str, Any],
adapter_config: dict[str, Any],
) -> bool:
"""Validate authentication using the YAML auth config.
Supports 'header' auth type: checks that a specified header
matches an expected value from adapter_config.
"""
auth_type = self._auth.get("type")
if not auth_type:
# No auth configured — accept all
return True
if auth_type == "header":
header_name = self._auth.get("header", "")
expected_key = "auth_token"
expected_value = adapter_config.get(expected_key, "")
actual_value = headers.get(header_name, "")
return bool(expected_value and actual_value == expected_value)
logger.warning(f"Unknown auth type '{auth_type}' in mapping adapter '{self._name}'")
return False
def get_config_schema(self) -> dict[str, Any]: # type: ignore[override]
"""Return config schema based on YAML auth requirements."""
properties: dict[str, Any] = {}
if self._auth.get("type") == "header":
properties["auth_token"] = {
"type": "string",
"title": "Auth Token",
"description": f"Value for the {self._auth.get('header', '')} header",
}
return {"type": "object", "properties": properties}
def get_setup_instructions(self) -> str: # type: ignore[override]
"""Return setup instructions from the YAML definition."""
return self._definition.get(
"setup_instructions",
f"Configure {self._game} to send data to the webhook endpoint.",
)
def load_adapter_from_yaml(path: str | Path) -> MappingAdapter:
"""Load a MappingAdapter from a YAML file.
Args:
path: Path to the YAML adapter definition file.
Returns:
A configured MappingAdapter instance.
Raises:
FileNotFoundError: If the file does not exist.
ValueError: If the YAML is invalid.
"""
path = Path(path)
if not path.exists():
raise FileNotFoundError(f"Adapter YAML file not found: {path}")
with open(path, encoding="utf-8") as f:
data = yaml.safe_load(f)
if not isinstance(data, dict):
raise ValueError(f"Adapter YAML must be a dict, got {type(data).__name__}")
errors = validate_adapter_yaml(data)
if errors:
raise ValueError(
f"Invalid adapter YAML '{path.name}':\n" + "\n".join(f" - {e}" for e in errors)
)
adapter = MappingAdapter(data)
logger.info(f"Loaded mapping adapter '{adapter.name}' for {adapter.game} from {path.name}")
return adapter
@@ -0,0 +1,208 @@
"""Built-in effect presets for game integrations.
Presets are read-only template configurations that can be applied to any
game integration. Each preset ships a curated set of event-to-effect
mappings tuned for a specific genre (FPS, MOBA, racing, generic).
Users can apply a preset via API, which merges the preset's mappings into
the integration's event_mappings list. After applying, mappings can be
freely edited.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import List
from wled_controller.storage.game_integration import EventMapping
@dataclass(frozen=True)
class EffectPreset:
"""A built-in effect preset.
Attributes:
key: Unique identifier for the preset (e.g. 'fps_combat').
name: Display name (e.g. 'FPS Combat').
description: One-line description of what the preset does.
target_game_types: Genre tags indicating which games this suits.
event_mappings: List of pre-configured event-to-effect mappings.
"""
key: str
name: str
description: str
target_game_types: List[str] = field(default_factory=list)
event_mappings: List[EventMapping] = field(default_factory=list)
# ── Built-in Presets ────────────────────────────────────────────────────
_PRESETS: dict[str, EffectPreset] = {}
def _reg(preset: EffectPreset) -> None:
"""Register a preset in the module-level registry."""
_PRESETS[preset.key] = preset
_reg(
EffectPreset(
key="fps_combat",
name="FPS Combat",
description="Health glow, kill flash, death pulse, round start sweep",
target_game_types=["fps"],
event_mappings=[
EventMapping(
event_type="health",
effect="breathing",
color=[255, 50, 50],
duration_ms=2000,
intensity=0.6,
priority=3,
),
EventMapping(
event_type="kill",
effect="flash",
color=[0, 255, 0],
duration_ms=400,
intensity=1.0,
priority=8,
),
EventMapping(
event_type="death",
effect="pulse",
color=[255, 0, 0],
duration_ms=1500,
intensity=1.0,
priority=10,
),
EventMapping(
event_type="round_start",
effect="sweep",
color=[0, 100, 255],
duration_ms=800,
intensity=0.8,
priority=5,
),
],
)
)
_reg(
EffectPreset(
key="moba_health",
name="MOBA Health",
description="Health gradient green-yellow-red, mana blue glow, death fade to black",
target_game_types=["moba"],
event_mappings=[
EventMapping(
event_type="health",
effect="color_shift",
color=[0, 255, 0],
duration_ms=1000,
intensity=0.7,
priority=4,
),
EventMapping(
event_type="mana",
effect="breathing",
color=[50, 100, 255],
duration_ms=2000,
intensity=0.5,
priority=3,
),
EventMapping(
event_type="death",
effect="pulse",
color=[20, 20, 20],
duration_ms=2500,
intensity=1.0,
priority=10,
),
],
)
)
_reg(
EffectPreset(
key="racing",
name="Racing",
description="Speed-based color temperature, boost rainbow flash",
target_game_types=["racing"],
event_mappings=[
EventMapping(
event_type="speed",
effect="color_shift",
color=[255, 120, 0],
duration_ms=500,
intensity=0.8,
priority=4,
),
EventMapping(
event_type="buffed",
effect="flash",
color=[128, 0, 255],
duration_ms=300,
intensity=1.0,
priority=9,
),
],
)
)
_reg(
EffectPreset(
key="generic_alert",
name="Generic Alert",
description="White flash on any trigger event",
target_game_types=["any"],
event_mappings=[
EventMapping(
event_type="kill",
effect="flash",
color=[255, 255, 255],
duration_ms=400,
intensity=1.0,
priority=5,
),
EventMapping(
event_type="death",
effect="flash",
color=[255, 255, 255],
duration_ms=400,
intensity=1.0,
priority=5,
),
EventMapping(
event_type="round_start",
effect="flash",
color=[255, 255, 255],
duration_ms=400,
intensity=1.0,
priority=5,
),
EventMapping(
event_type="objective_captured",
effect="flash",
color=[255, 255, 255],
duration_ms=400,
intensity=1.0,
priority=5,
),
],
)
)
# ── Public API ──────────────────────────────────────────────────────────
def get_all_presets() -> list[EffectPreset]:
"""Return all built-in presets."""
return list(_PRESETS.values())
def get_preset(key: str) -> EffectPreset | None:
"""Look up a preset by key."""
return _PRESETS.get(key)
@@ -0,0 +1,156 @@
"""Steam installation and game config path detection.
Finds the Steam root directory via Windows registry, common paths, and
environment variables. Locates game-specific cfg directories by checking
Steam library folders (including multiple library locations from
libraryfolders.vdf).
"""
import platform
import re
from pathlib import Path
from wled_controller.utils import get_logger
logger = get_logger(__name__)
# Game IDs and relative cfg paths within steamapps/common/
_GAME_PATHS: dict[str, dict[str, str]] = {
"cs2": {
"app_id": "730",
"install_dir": "Counter-Strike Global Offensive",
"cfg_subpath": "game/csgo/cfg",
},
"dota2": {
"app_id": "570",
"install_dir": "dota 2 beta",
"cfg_subpath": "game/dota/cfg",
},
}
def find_steam_root() -> Path | None:
"""Detect the Steam installation directory.
Strategy:
1. Windows: read HKCU\\Software\\Valve\\Steam\\SteamPath
2. Fall back to common paths per platform
3. Check STEAM_DIR environment variable
Returns:
Path to Steam root, or None if not found.
"""
import os
system = platform.system()
# ── Windows registry ──
if system == "Windows":
try:
import winreg
key = winreg.OpenKey(winreg.HKEY_CURRENT_USER, r"Software\Valve\Steam")
steam_path, _ = winreg.QueryValueEx(key, "SteamPath")
winreg.CloseKey(key)
p = Path(str(steam_path))
if p.is_dir():
logger.debug("Steam found via registry: %s", p)
return p
except (OSError, ImportError):
pass
# ── Common paths ──
common_paths: list[Path] = []
if system == "Windows":
common_paths = [
Path("C:/Program Files (x86)/Steam"),
Path("C:/Program Files/Steam"),
]
elif system == "Linux":
home = Path.home()
common_paths = [
home / ".steam" / "root",
home / ".steam" / "steam",
home / ".local" / "share" / "Steam",
]
elif system == "Darwin":
common_paths = [
Path.home() / "Library" / "Application Support" / "Steam",
]
for candidate in common_paths:
if candidate.is_dir():
logger.debug("Steam found at common path: %s", candidate)
return candidate
# ── Environment variable fallback ──
env_dir = os.environ.get("STEAM_DIR")
if env_dir:
p = Path(env_dir)
if p.is_dir():
logger.debug("Steam found via STEAM_DIR env: %s", p)
return p
logger.debug("Steam installation not found")
return None
def _parse_library_folders(steam_root: Path) -> list[Path]:
"""Parse libraryfolders.vdf to find all Steam library locations.
Returns a list of library root paths (each containing a steamapps/ dir).
The steam_root itself is always included as the first entry.
"""
libraries: list[Path] = [steam_root]
vdf_path = steam_root / "steamapps" / "libraryfolders.vdf"
if not vdf_path.is_file():
# Older Steam versions use config/libraryfolders.vdf
vdf_path = steam_root / "config" / "libraryfolders.vdf"
if not vdf_path.is_file():
return libraries
try:
content = vdf_path.read_text(encoding="utf-8", errors="replace")
# Match "path" entries in the VDF file
# Format: "path" "C:\\SteamLibrary"
for match in re.finditer(r'"path"\s+"([^"]+)"', content):
lib_path = Path(match.group(1).replace("\\\\", "\\"))
if lib_path.is_dir() and lib_path not in libraries:
libraries.append(lib_path)
except OSError as e:
logger.warning("Failed to read libraryfolders.vdf: %s", e)
return libraries
def find_game_cfg_path(game: str) -> Path | None:
"""Find the cfg directory for a supported game.
Args:
game: Game identifier ("cs2" or "dota2").
Returns:
Path to the game's cfg directory, or None if not found.
"""
game_info = _GAME_PATHS.get(game)
if not game_info:
logger.warning("Unknown game identifier: %s", game)
return None
steam_root = find_steam_root()
if not steam_root:
return None
libraries = _parse_library_folders(steam_root)
for lib in libraries:
cfg_path = (
lib / "steamapps" / "common" / game_info["install_dir"] / game_info["cfg_subpath"]
)
if cfg_path.is_dir():
logger.debug("Found %s cfg at: %s", game, cfg_path)
return cfg_path
logger.debug("Game cfg not found for %s in any library folder", game)
return None
@@ -24,6 +24,7 @@ from wled_controller.core.processing.api_input_stream import ApiInputColorStripS
from wled_controller.core.processing.notification_stream import NotificationColorStripStream
from wled_controller.core.processing.daylight_stream import DaylightColorStripStream
from wled_controller.core.processing.candlelight_stream import CandlelightColorStripStream
from wled_controller.core.processing.game_event_stream import GameEventColorStripStream
from wled_controller.utils import get_logger
logger = get_logger(__name__)
@@ -38,6 +39,7 @@ _SIMPLE_STREAM_MAP = {
"notification": NotificationColorStripStream,
"daylight": DaylightColorStripStream,
"candlelight": CandlelightColorStripStream,
"game_event": GameEventColorStripStream,
}
@@ -86,6 +88,7 @@ class ColorStripStreamManager:
gradient_store=None,
weather_manager=None,
asset_store=None,
game_event_bus=None,
):
"""
Args:
@@ -97,6 +100,7 @@ class ColorStripStreamManager:
value_stream_manager: ValueStreamManager for per-layer brightness sources
cspt_store: ColorStripProcessingTemplateStore for per-layer filter chains
gradient_store: GradientStore for resolving gradient entity references
game_event_bus: GameEventBus for game event stream subscriptions
"""
self._color_strip_store = color_strip_store
self._live_stream_manager = live_stream_manager
@@ -109,6 +113,7 @@ class ColorStripStreamManager:
self._gradient_store = gradient_store
self._weather_manager = weather_manager
self._asset_store = asset_store
self._game_event_bus = game_event_bus
self._streams: Dict[str, _ColorStripEntry] = {}
def _inject_clock(self, css_stream, source) -> Optional[str]:
@@ -273,6 +278,9 @@ class ColorStripStreamManager:
# Inject asset store for notification sound playback
if self._asset_store and hasattr(css_stream, "set_asset_store"):
css_stream.set_asset_store(self._asset_store)
# Inject game event bus for game event streams
if self._game_event_bus and hasattr(css_stream, "set_event_bus"):
css_stream.set_event_bus(self._game_event_bus)
# Inject sync clock runtime if source references a clock
acquired_clock_id = self._inject_clock(css_stream, source)
css_stream.start()
@@ -0,0 +1,440 @@
"""Game event color strip stream — renders LED effects on game events.
Subscribes to a GameEventBus and renders animated LED effects (flash, pulse,
sweep, color_shift, breathing) when matching game events arrive. When idle,
outputs the configured idle_color.
Thread-safe: event callbacks arrive from the EventBus dispatch thread while
get_latest_colors() is called from the target processor thread.
Uses a background render loop at 30 FPS with double-buffered output.
"""
import collections
import math
import threading
import time
from typing import Optional
import numpy as np
from wled_controller.core.game_integration.event_bus import GameEventBus
from wled_controller.core.game_integration.events import GameEvent
from wled_controller.core.processing.color_strip_stream import ColorStripStream
from wled_controller.storage.bindable import bcolor
from wled_controller.storage.game_integration import EventMapping
from wled_controller.utils import get_logger
logger = get_logger(__name__)
class GameEventColorStripStream(ColorStripStream):
"""Color strip stream that renders effects in response to game events.
Supports five effect types:
- flash: linear fade from full brightness to zero
- pulse: smooth bell curve (sin)
- sweep: fill LEDs left-to-right, then fade out
- color_shift: gradual hue rotation from the event color
- breathing: slow sine-wave brightness oscillation
Uses collections.deque for thread-safe event passing and threading.Lock
for the output color buffer. Priority-based layering: higher priority
effects override lower ones (same as notification stream).
"""
def __init__(self, source, event_bus: Optional[GameEventBus] = None) -> None:
self._colors_lock = threading.Lock()
self._running = False
self._thread: Optional[threading.Thread] = None
self._fps = 30
self._frame_time = 1.0 / 30
# Event queue: deque of effect dicts
self._event_queue: collections.deque = collections.deque(maxlen=32)
# Active effect state
self._active_effect: Optional[dict] = None
# EventBus reference and subscription IDs
self._event_bus = event_bus
self._subscription_ids: list[str] = []
self._update_from_source(source)
def _update_from_source(self, source) -> None:
"""Parse config from source dataclass."""
self._idle_color = bcolor(getattr(source, "idle_color", None), [0, 0, 0])
self._game_integration_id = getattr(source, "game_integration_id", "")
self._auto_size = not getattr(source, "led_count", 0)
self._led_count = (
getattr(source, "led_count", 0) if getattr(source, "led_count", 0) > 0 else 1
)
# Parse event_mappings into lookup dict: event_type -> EventMapping
self._mapping_lookup: dict[str, EventMapping] = {}
raw_mappings = getattr(source, "event_mappings", [])
for m in raw_mappings:
if isinstance(m, dict):
try:
mapping = EventMapping.from_dict(m)
self._mapping_lookup[mapping.event_type] = mapping
except (KeyError, TypeError):
logger.warning(f"Skipping invalid event mapping: {m}")
elif isinstance(m, EventMapping):
self._mapping_lookup[m.event_type] = m
with self._colors_lock:
idle = self.resolve_color("idle_color", self._idle_color)
self._colors: Optional[np.ndarray] = np.zeros(
(self._led_count, 3),
dtype=np.uint8,
)
self._colors[:, 0] = idle[0]
self._colors[:, 1] = idle[1]
self._colors[:, 2] = idle[2]
def set_event_bus(self, event_bus: GameEventBus) -> None:
"""Inject or replace the EventBus (called by stream manager)."""
self._event_bus = event_bus
def configure(self, device_led_count: int) -> None:
"""Set LED count from the target device (called on target start)."""
if self._auto_size and device_led_count > 0:
new_count = max(self._led_count, device_led_count)
if new_count != self._led_count:
self._led_count = new_count
with self._colors_lock:
self._colors = np.zeros((new_count, 3), dtype=np.uint8)
logger.debug(
f"GameEventColorStripStream auto-sized to {new_count} LEDs",
)
@property
def target_fps(self) -> int:
return self._fps
def set_capture_fps(self, fps: int) -> None:
self._fps = max(1, min(90, fps))
self._frame_time = 1.0 / self._fps
@property
def is_animated(self) -> bool:
return True
@property
def led_count(self) -> int:
return self._led_count
def start(self) -> None:
if self._running:
return
self._running = True
self._subscribe_to_events()
self._thread = threading.Thread(
target=self._render_loop,
name="css-game-event",
daemon=True,
)
self._thread.start()
logger.info(
f"GameEventColorStripStream started "
f"(leds={self._led_count}, mappings={len(self._mapping_lookup)})",
)
def stop(self) -> None:
self._running = False
self._unsubscribe_from_events()
if self._thread:
self._thread.join(timeout=5.0)
if self._thread.is_alive():
logger.warning(
"GameEventColorStripStream render thread did not terminate within 5s",
)
self._thread = None
logger.info("GameEventColorStripStream stopped")
def get_latest_colors(self) -> Optional[np.ndarray]:
with self._colors_lock:
return self._colors
def update_source(self, source) -> None:
"""Hot-update config from updated source."""
from wled_controller.storage.color_strip_source import GameEventColorStripSource
if isinstance(source, GameEventColorStripSource):
prev_led_count = self._led_count if self._auto_size else None
was_running = self._running
# Re-subscribe if integration changed
old_integration = self._game_integration_id
self._update_from_source(source)
if prev_led_count and self._auto_size:
self._led_count = prev_led_count
with self._colors_lock:
self._colors = np.zeros((self._led_count, 3), dtype=np.uint8)
# If mappings changed, re-subscribe
if was_running and old_integration != self._game_integration_id:
self._unsubscribe_from_events()
self._subscribe_to_events()
logger.info("GameEventColorStripStream params updated in-place")
def set_clock(self, clock) -> None:
"""Set or clear the sync clock runtime (not used for game events)."""
pass
# ── EventBus subscription ────────────────────────────────────────
def _subscribe_to_events(self) -> None:
"""Subscribe to EventBus for all mapped event types."""
if not self._event_bus:
logger.warning(
"GameEventColorStripStream: no EventBus available, "
"events will not trigger effects",
)
return
if not self._mapping_lookup:
return
for event_type in self._mapping_lookup:
sub_id = self._event_bus.subscribe(event_type, self._on_game_event)
self._subscription_ids.append(sub_id)
logger.debug(
f"GameEventColorStripStream subscribed to {len(self._subscription_ids)} "
f"event types",
)
def _unsubscribe_from_events(self) -> None:
"""Unsubscribe all active subscriptions."""
if self._event_bus:
for sub_id in self._subscription_ids:
self._event_bus.unsubscribe(sub_id)
self._subscription_ids.clear()
def _on_game_event(self, event: GameEvent) -> None:
"""Callback from EventBus — enqueue an effect (thread-safe)."""
mapping = self._mapping_lookup.get(event.event_type)
if mapping is None:
return
color = tuple(mapping.color)
self._event_queue.append(
{
"color": color,
"start": time.monotonic(),
"priority": mapping.priority,
"effect": mapping.effect,
"duration_ms": mapping.duration_ms,
"intensity": mapping.intensity,
}
)
# ── Render loop ──────────────────────────────────────────────────
def _render_loop(self) -> None:
"""Background thread rendering at 30 FPS."""
_pool_n = 0
_buf_a = _buf_b = None
_use_a = True
try:
while self._running:
wall_start = time.perf_counter()
frame_time = self._frame_time
try:
# Check for new events — higher priority overrides current
while self._event_queue:
try:
event = self._event_queue.popleft()
if self._active_effect is None or event.get(
"priority", 0
) >= self._active_effect.get("priority", 0):
self._active_effect = event
except IndexError:
break
n = self._led_count
# Reallocate buffers if LED count changed
if n != _pool_n:
_pool_n = n
_buf_a = np.zeros((n, 3), dtype=np.uint8)
_buf_b = np.zeros((n, 3), dtype=np.uint8)
buf = _buf_a if _use_a else _buf_b
_use_a = not _use_a
if self._active_effect is not None:
color = self._active_effect["color"]
start_time = self._active_effect["start"]
elapsed_ms = (time.monotonic() - start_time) * 1000.0
duration_ms = self._active_effect.get("duration_ms", 500)
progress = min(elapsed_ms / max(duration_ms, 1), 1.0)
if progress >= 1.0:
# Effect complete — return to idle
self._active_effect = None
idle = self.resolve_color("idle_color", self._idle_color)
buf[:, 0] = idle[0]
buf[:, 1] = idle[1]
buf[:, 2] = idle[2]
else:
intensity = self._active_effect.get("intensity", 1.0)
self._render_effect(
buf,
n,
color,
progress,
self._active_effect.get("effect", "flash"),
intensity,
)
else:
# Idle: output idle_color
idle = self.resolve_color("idle_color", self._idle_color)
buf[:, 0] = idle[0]
buf[:, 1] = idle[1]
buf[:, 2] = idle[2]
with self._colors_lock:
self._colors = buf
except Exception as e:
logger.error(f"GameEventColorStripStream render error: {e}")
elapsed = time.perf_counter() - wall_start
time.sleep(max(frame_time - elapsed, 0.001))
except Exception as e:
logger.error(
f"Fatal GameEventColorStripStream loop error: {e}",
exc_info=True,
)
finally:
self._running = False
# ── Effect renderers ─────────────────────────────────────────────
def _render_effect(
self,
buf: np.ndarray,
n: int,
color: tuple,
progress: float,
effect: str,
intensity: float,
) -> None:
"""Dispatch to the appropriate effect renderer."""
if effect == "pulse":
self._render_pulse(buf, n, color, progress, intensity)
elif effect == "sweep":
self._render_sweep(buf, n, color, progress, intensity)
elif effect == "color_shift":
self._render_color_shift(buf, n, color, progress, intensity)
elif effect == "breathing":
self._render_breathing(buf, n, color, progress, intensity)
else:
# Default: flash
self._render_flash(buf, n, color, progress, intensity)
def _render_flash(
self,
buf: np.ndarray,
n: int,
color: tuple,
progress: float,
intensity: float,
) -> None:
"""Flash effect: linear fade from full brightness to zero."""
brightness = max(0.0, (1.0 - progress) * intensity)
buf[:, 0] = int(color[0] * brightness)
buf[:, 1] = int(color[1] * brightness)
buf[:, 2] = int(color[2] * brightness)
def _render_pulse(
self,
buf: np.ndarray,
n: int,
color: tuple,
progress: float,
intensity: float,
) -> None:
"""Pulse effect: smooth bell curve (sin)."""
brightness = math.sin(progress * math.pi) * intensity
buf[:, 0] = int(color[0] * brightness)
buf[:, 1] = int(color[1] * brightness)
buf[:, 2] = int(color[2] * brightness)
def _render_sweep(
self,
buf: np.ndarray,
n: int,
color: tuple,
progress: float,
intensity: float,
) -> None:
"""Sweep effect: fill LEDs left-to-right, then fade all."""
if progress < 0.5:
fill_progress = progress * 2.0
fill_pos = int(fill_progress * n)
buf[:] = 0
if fill_pos > 0:
buf[:fill_pos, 0] = int(color[0] * intensity)
buf[:fill_pos, 1] = int(color[1] * intensity)
buf[:fill_pos, 2] = int(color[2] * intensity)
else:
fade_progress = (progress - 0.5) * 2.0
brightness = max(0.0, (1.0 - fade_progress) * intensity)
buf[:, 0] = int(color[0] * brightness)
buf[:, 1] = int(color[1] * brightness)
buf[:, 2] = int(color[2] * brightness)
def _render_color_shift(
self,
buf: np.ndarray,
n: int,
color: tuple,
progress: float,
intensity: float,
) -> None:
"""Color shift effect: gradual hue rotation from the event color.
Rotates the hue by up to 180 degrees over the effect duration while
fading brightness with intensity.
"""
import colorsys
r, g, b = color[0] / 255.0, color[1] / 255.0, color[2] / 255.0
h, s, v = colorsys.rgb_to_hsv(r, g, b)
# Rotate hue by up to 0.5 (180 degrees)
shifted_h = (h + progress * 0.5) % 1.0
# Fade brightness over time
shifted_v = v * intensity * max(0.0, 1.0 - progress * 0.5)
sr, sg, sb = colorsys.hsv_to_rgb(shifted_h, s, shifted_v)
buf[:, 0] = min(255, int(sr * 255))
buf[:, 1] = min(255, int(sg * 255))
buf[:, 2] = min(255, int(sb * 255))
def _render_breathing(
self,
buf: np.ndarray,
n: int,
color: tuple,
progress: float,
intensity: float,
) -> None:
"""Breathing effect: slow sine-wave brightness oscillation.
Performs two full breathing cycles over the effect duration.
"""
# Two full sin cycles over the duration
brightness = (math.sin(progress * 4 * math.pi - math.pi / 2) + 1.0) / 2.0
brightness *= intensity
buf[:, 0] = int(color[0] * brightness)
buf[:, 1] = int(color[1] * brightness)
buf[:, 2] = int(color[2] * brightness)
@@ -72,6 +72,7 @@ class ProcessorDependencies:
weather_manager: Optional[WeatherManager] = None
asset_store: Optional[AssetStore] = None
ha_manager: Optional[Any] = None # HomeAssistantManager
game_event_bus: Optional[Any] = None # GameEventBus
@dataclass
@@ -151,6 +152,7 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
gradient_store=deps.gradient_store,
weather_manager=deps.weather_manager,
asset_store=deps.asset_store,
game_event_bus=deps.game_event_bus,
)
self._value_stream_manager = (
ValueStreamManager(
@@ -162,6 +164,7 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
ha_manager=deps.ha_manager,
css_stream_manager=self._color_strip_stream_manager,
gradient_store=deps.gradient_store,
event_bus=deps.game_event_bus,
)
if deps.value_source_store
else None
@@ -33,6 +33,7 @@ from wled_controller.utils import get_logger
if TYPE_CHECKING:
from wled_controller.core.audio.audio_capture import AudioCaptureManager
from wled_controller.core.game_integration.event_bus import GameEventBus
from wled_controller.core.home_assistant.ha_manager import HomeAssistantManager
from wled_controller.core.processing.color_strip_stream_manager import ColorStripStreamManager
from wled_controller.core.processing.live_stream_manager import LiveStreamManager
@@ -777,6 +778,18 @@ class AdaptiveTimeColorValueStream(ValueStream):
# HA Entity
# ---------------------------------------------------------------------------
# Common HA boolean states mapped to numeric values for value sources
_HA_BOOL_MAP: Dict[str, float] = {
"on": 1.0,
"off": 0.0,
"home": 1.0,
"away": 0.0,
"open": 1.0,
"closed": 0.0,
"true": 1.0,
"false": 0.0,
}
class HAEntityValueStream(ValueStream):
"""Reads a numeric value from a Home Assistant entity state or attribute.
@@ -855,7 +868,11 @@ class HAEntityValueStream(ValueStream):
raw_str = attrs.get(self._attribute, getattr(state, "state", "0"))
else:
raw_str = getattr(state, "state", "0")
raw = float(raw_str)
raw_lower = raw_str.lower() if isinstance(raw_str, str) else raw_str
if raw_lower in _HA_BOOL_MAP:
raw = _HA_BOOL_MAP[raw_lower]
else:
raw = float(raw_str)
except (ValueError, TypeError):
return self._prev_value if self._prev_value is not None else 0.0
@@ -1394,6 +1411,7 @@ class ValueStreamManager:
ha_manager: Optional["HomeAssistantManager"] = None,
css_stream_manager: Optional["ColorStripStreamManager"] = None,
gradient_store: Optional[Any] = None,
event_bus: Optional["GameEventBus"] = None,
):
self._value_source_store = value_source_store
self._audio_capture_manager = audio_capture_manager
@@ -1403,6 +1421,7 @@ class ValueStreamManager:
self._ha_manager = ha_manager
self._css_stream_manager = css_stream_manager
self._gradient_store = gradient_store
self._event_bus = event_bus
self._streams: Dict[str, ValueStream] = {} # vs_id → stream
self._ref_counts: Dict[str, int] = {} # vs_id → ref count
@@ -1474,6 +1493,7 @@ class ValueStreamManager:
AudioValueSource,
CSSExtractValueSource,
DaylightValueSource,
GameEventValueSource,
GradientMapValueSource,
HAEntityValueSource,
StaticValueSource,
@@ -1588,5 +1608,20 @@ class ValueStreamManager:
smoothing=source.smoothing,
)
if isinstance(source, GameEventValueSource):
from wled_controller.core.value_sources.game_event_value_source import (
GameEventValueStream,
)
return GameEventValueStream(
event_type=source.event_type,
min_game_value=source.min_game_value,
max_game_value=source.max_game_value,
smoothing=source.smoothing,
default_value=source.default_value,
timeout=source.timeout,
event_bus=self._event_bus,
)
# Fallback
return StaticValueStream(value=1.0)
@@ -0,0 +1,142 @@
"""GameEventValueStream — value stream driven by game events.
Subscribes to the GameEventBus for a configured event type, normalizes
incoming game values to 0.0-1.0 using min/max mapping, applies optional
EMA smoothing, and reverts to a default value on timeout.
Thread-safe: the EventBus callback runs on the publisher's thread while
get_value() is called from the render thread.
"""
from __future__ import annotations
import threading
import time
from typing import TYPE_CHECKING, Optional
from wled_controller.core.processing.value_stream import ValueStream
from wled_controller.utils import get_logger
if TYPE_CHECKING:
from wled_controller.core.game_integration.event_bus import GameEventBus
from wled_controller.core.game_integration.events import GameEvent
from wled_controller.storage.value_source import GameEventValueSource
logger = get_logger(__name__)
class GameEventValueStream(ValueStream):
"""Runtime resolver that exposes game metrics as 0.0-1.0 scalars.
Subscribes to events of a specific type on the GameEventBus,
normalizes raw game values, applies EMA smoothing, and handles
timeout (reverts to default_value when no events arrive).
"""
def __init__(
self,
event_type: str,
min_game_value: float = 0.0,
max_game_value: float = 100.0,
smoothing: float = 0.0,
default_value: float = 0.5,
timeout: float = 5.0,
event_bus: Optional["GameEventBus"] = None,
) -> None:
self._event_type = event_type
self._min_game = min_game_value
self._max_game = max_game_value
self._smoothing = max(0.0, min(1.0, smoothing))
self._default_value = max(0.0, min(1.0, default_value))
self._timeout = max(0.0, timeout)
self._event_bus = event_bus
self._lock = threading.Lock()
self._current_value: float = self._default_value
self._last_event_time: Optional[float] = None
self._subscription_id: Optional[str] = None
self._has_received_event: bool = False
def start(self) -> None:
"""Subscribe to the EventBus for the configured event type."""
if self._event_bus is not None:
self._subscription_id = self._event_bus.subscribe(
self._event_type,
self._on_event,
)
logger.info(
"GameEventValueStream started (event_type=%s, sub=%s)",
self._event_type,
self._subscription_id,
)
def stop(self) -> None:
"""Unsubscribe from the EventBus and reset state."""
if self._event_bus is not None and self._subscription_id is not None:
self._event_bus.unsubscribe(self._subscription_id)
logger.info(
"GameEventValueStream stopped (event_type=%s, sub=%s)",
self._event_type,
self._subscription_id,
)
self._subscription_id = None
with self._lock:
self._current_value = self._default_value
self._last_event_time = None
self._has_received_event = False
def get_value(self) -> float:
"""Return current normalized value (0.0-1.0), or default if timed out."""
with self._lock:
if not self._has_received_event:
return self._default_value
if self._timeout > 0.0 and self._last_event_time is not None:
elapsed = time.monotonic() - self._last_event_time
if elapsed > self._timeout:
return self._default_value
return self._current_value
def get_color(self) -> tuple:
"""Game event value source only provides scalars, not colors."""
raise NotImplementedError("GameEventValueStream does not produce colors")
def update_source(self, source: "GameEventValueSource") -> None:
"""Hot-update parameters from a modified GameEventValueSource config."""
from wled_controller.storage.value_source import GameEventValueSource as GEVS
if not isinstance(source, GEVS):
return
with self._lock:
self._min_game = source.min_game_value
self._max_game = source.max_game_value
self._smoothing = max(0.0, min(1.0, source.smoothing))
self._default_value = max(0.0, min(1.0, source.default_value))
self._timeout = max(0.0, source.timeout)
def _on_event(self, event: "GameEvent") -> None:
"""EventBus callback — normalize and apply smoothing.
Called from the publisher's thread; must be thread-safe.
"""
raw_value = event.value
normalized = self._normalize(raw_value)
with self._lock:
if self._smoothing > 0.0 and self._has_received_event:
alpha = 1.0 - self._smoothing
normalized = alpha * normalized + self._smoothing * self._current_value
self._current_value = normalized
self._last_event_time = time.monotonic()
self._has_received_event = True
def _normalize(self, raw_value: float) -> float:
"""Map a raw game value to the 0.0-1.0 range using min/max."""
game_range = self._max_game - self._min_game
if abs(game_range) < 1e-9:
return 0.5
normalized = (raw_value - self._min_game) / game_range
return max(0.0, min(1.0, normalized))
+10
View File
@@ -44,6 +44,10 @@ from wled_controller.core.weather.weather_manager import WeatherManager
from wled_controller.storage.home_assistant_store import HomeAssistantStore
from wled_controller.core.home_assistant.ha_manager import HomeAssistantManager
from wled_controller.core.automations.automation_engine import AutomationEngine
from wled_controller.storage.game_integration_store import GameIntegrationStore
from wled_controller.core.game_integration.event_bus import GameEventBus
import wled_controller.core.game_integration.adapters # noqa: F401 — register built-in adapters
from wled_controller.core.game_integration.community_loader import register_community_adapters
from wled_controller.core.mqtt.mqtt_service import MQTTService
from wled_controller.core.devices.mqtt_client import set_mqtt_service
from wled_controller.core.backup.auto_backup import AutoBackupEngine
@@ -97,6 +101,9 @@ sync_clock_manager = SyncClockManager(sync_clock_store)
weather_manager = WeatherManager(weather_source_store)
ha_store = HomeAssistantStore(db)
ha_manager = HomeAssistantManager(ha_store)
game_integration_store = GameIntegrationStore(db)
game_event_bus = GameEventBus()
register_community_adapters()
processor_manager = ProcessorManager(
ProcessorDependencies(
@@ -114,6 +121,7 @@ processor_manager = ProcessorManager(
weather_manager=weather_manager,
asset_store=asset_store,
ha_manager=ha_manager,
game_event_bus=game_event_bus,
)
)
@@ -212,6 +220,8 @@ async def lifespan(app: FastAPI):
asset_store=asset_store,
ha_store=ha_store,
ha_manager=ha_manager,
game_integration_store=game_integration_store,
game_event_bus=game_event_bus,
)
# Register devices in processor manager for health monitoring
@@ -15,4 +15,5 @@
@import './tutorials.css';
@import './graph-editor.css';
@import './appearance.css';
@import './game-integration.css';
@import './mobile.css';
@@ -27,7 +27,7 @@
padding: 0 4px;
}
/* Automation condition pills — constrain to card width */
/* Automation rule pills — constrain to card width */
[data-automation-id] .card-meta {
display: flex;
flex-wrap: wrap;
@@ -41,8 +41,8 @@
white-space: nowrap;
}
/* Automation condition editor rows */
.automation-condition-row {
/* Automation rule editor rows */
.automation-rule-row {
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 10px;
@@ -50,19 +50,19 @@
background: var(--bg-secondary, var(--bg-color));
}
.condition-header {
.rule-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.condition-type-label {
.rule-type-label {
font-weight: 600;
font-size: 0.9rem;
}
.condition-type-select {
.rule-type-select {
font-weight: 600;
font-size: 0.9rem;
padding: 2px 6px;
@@ -72,13 +72,13 @@
color: var(--text-color);
}
.condition-always-desc {
.rule-hint-desc {
display: block;
color: var(--text-muted);
font-size: 0.85rem;
}
.btn-remove-condition {
.btn-remove-rule {
background: none;
border: none;
color: var(--danger-color, #dc3545);
@@ -88,22 +88,22 @@
transition: opacity 0.15s;
}
.btn-remove-condition:hover {
.btn-remove-rule:hover {
opacity: 1;
}
.btn-remove-condition .icon {
.btn-remove-rule .icon {
width: 16px;
height: 16px;
}
.condition-fields {
.rule-fields {
display: flex;
flex-direction: column;
gap: 8px;
}
.condition-field label {
.rule-field label {
display: block;
font-size: 0.85rem;
margin-bottom: 3px;
@@ -202,8 +202,8 @@
}
.condition-field select,
.condition-field textarea {
.rule-field select,
.rule-field textarea {
width: 100%;
padding: 6px 8px;
border: 1px solid var(--border-color);
@@ -214,12 +214,12 @@
font-family: inherit;
}
.condition-apps {
.rule-apps {
resize: vertical;
min-height: 60px;
}
.condition-apps-header {
.rule-apps-header {
display: flex;
justify-content: space-between;
align-items: center;
@@ -0,0 +1,241 @@
/* ── Game Integration ── */
/* Status indicator badges */
.gi-status-active {
color: var(--success-color);
}
.gi-status-inactive {
color: var(--text-muted);
}
/* Mapping editor toolbar */
.gi-mapping-toolbar {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.gi-mapping-toolbar select {
flex: 0 0 auto;
max-width: 200px;
}
/* Mapping list */
.gi-mappings-list {
display: flex;
flex-direction: column;
gap: 8px;
}
/* Single mapping row — collapsible item (matches composite-layer-item) */
.gi-mapping-row {
display: flex;
flex-direction: column;
gap: 4px;
padding: 10px;
border: 1px solid var(--border-color);
border-radius: 6px;
background: var(--bg-secondary, var(--bg-color));
}
/* Header — always visible summary */
.gi-mapping-header {
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
user-select: none;
}
.gi-mapping-expand-btn {
font-size: 0.6rem;
color: var(--text-secondary);
transition: transform 0.15s ease;
flex-shrink: 0;
width: 12px;
text-align: center;
}
.gi-mapping-expanded .gi-mapping-expand-btn {
transform: rotate(90deg);
}
.gi-mapping-summary {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 8px;
overflow: hidden;
}
.gi-mapping-summary-event {
font-size: 0.85rem;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.gi-mapping-summary-effect {
font-size: 0.75rem;
color: var(--text-secondary);
background: var(--bg-color);
padding: 1px 6px;
border-radius: 3px;
white-space: nowrap;
flex-shrink: 0;
}
.gi-mapping-summary-color {
width: 14px;
height: 14px;
border-radius: 50%;
border: 1px solid var(--border-color);
flex-shrink: 0;
}
/* Collapsible body — CSS grid transition (matches composite-layer) */
.gi-mapping-body-wrapper {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 0.2s ease;
}
.gi-mapping-expanded .gi-mapping-body-wrapper {
grid-template-rows: 1fr;
}
.gi-mapping-body {
display: flex;
flex-direction: column;
gap: 8px;
padding-top: 0;
overflow: hidden;
min-height: 0;
transition: padding-top 0.2s ease;
font-size: 0.85rem;
}
.gi-mapping-expanded .gi-mapping-body {
padding-top: 8px;
}
/* Field rows inside body */
.gi-mapping-field-row {
display: flex;
align-items: center;
gap: 6px;
}
.gi-mapping-field-row label {
font-size: 0.85rem;
color: var(--text-muted);
min-width: 70px;
flex-shrink: 0;
}
.gi-mapping-field-row select,
.gi-mapping-field-row input[type="text"],
.gi-mapping-field-row input[type="number"] {
flex: 1;
min-width: 0;
padding: 6px 8px;
border: 1px solid var(--border-color);
border-radius: 4px;
background: var(--bg-color);
color: var(--text-color);
font-size: 0.9rem;
font-family: inherit;
}
.gi-mapping-field-row input[type="range"] {
flex: 1;
min-width: 0;
}
.gi-mapping-field-row input[type="color"] {
width: 40px;
height: 30px;
padding: 0;
border: 1px solid var(--border-color);
border-radius: 4px;
cursor: pointer;
}
/* Setup instructions pre block */
.gi-instructions-pre {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 12px;
font-family: var(--font-mono, monospace);
font-size: 0.85em;
white-space: pre-wrap;
word-break: break-all;
max-height: 200px;
overflow-y: auto;
}
/* Live event feed */
.gi-event-feed {
max-height: 200px;
overflow-y: auto;
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 8px;
background: var(--bg-secondary);
font-size: 0.85em;
}
.gi-event-waiting {
color: var(--text-muted);
text-align: center;
padding: 16px;
}
.gi-event-item {
display: flex;
gap: 8px;
padding: 3px 0;
border-bottom: 1px solid var(--border-color);
font-family: var(--font-mono, monospace);
font-size: 0.9em;
}
.gi-event-item:last-child {
border-bottom: none;
}
.gi-event-time {
color: var(--text-muted);
flex-shrink: 0;
}
.gi-event-type {
color: var(--primary-text-color);
font-weight: 600;
}
.gi-event-value {
color: var(--text-secondary);
}
/* Connection test panel */
.gi-test-panel {
margin-top: 8px;
padding: 12px;
border-radius: 6px;
border: 1px solid var(--border-color);
background: var(--bg-secondary);
}
.gi-test-waiting {
color: var(--warning-color);
}
.gi-test-success {
color: var(--success-color);
}
.gi-test-error {
color: var(--danger-color);
}
.gi-test-timeout {
color: var(--text-muted);
}
/* Responsive mapping rows */
@media (max-width: 768px) {
.gi-mapping-field-row {
flex-direction: column;
align-items: stretch;
}
.gi-mapping-field-row label {
min-width: unset;
}
}
+27 -2
View File
@@ -81,9 +81,17 @@ import {
} from './features/pattern-templates.ts';
import {
loadAutomations, switchAutomationTab, openAutomationEditor, closeAutomationEditorModal,
saveAutomationEditor, addAutomationCondition,
saveAutomationEditor, addAutomationRule,
toggleAutomationEnabled, cloneAutomation, deleteAutomation, copyWebhookUrl,
} from './features/automations.ts';
import {
showGameIntegrationEditor, saveGameIntegration, closeGameIntegrationModal,
cloneGameIntegration, deleteGameIntegration,
addGameMapping, removeGameMapping, onMappingPresetChange,
testGameConnection, showGameEventMonitor,
openSetupInstructions, closeSetupInstructions,
autoSetupGameIntegration,
} from './features/game-integration.ts';
import {
openScenePresetCapture, editScenePreset, saveScenePreset, closeScenePresetEditor,
activateScenePreset, cloneScenePreset, deleteScenePreset,
@@ -132,6 +140,7 @@ import {
testNotification,
showNotificationHistory, closeNotificationHistory, refreshNotificationHistory,
testColorStrip, testCSPT, closeTestCssSourceModal, applyCssTestSettings, fireCssTestNotification, fireCssTestNotificationLayer,
addCSSGameMapping, removeCSSGameMapping, onCSSGameMappingPresetChange,
} from './features/color-strips.ts';
// Layer 5: audio sources
@@ -369,7 +378,7 @@ Object.assign(window, {
openAutomationEditor,
closeAutomationEditorModal,
saveAutomationEditor,
addAutomationCondition,
addAutomationRule,
toggleAutomationEnabled,
cloneAutomation,
deleteAutomation,
@@ -385,6 +394,21 @@ Object.assign(window, {
deleteScenePreset,
addSceneTarget,
// game integration
showGameIntegrationEditor,
saveGameIntegration,
closeGameIntegrationModal,
cloneGameIntegration,
deleteGameIntegration,
addGameMapping,
removeGameMapping,
onMappingPresetChange,
testGameConnection,
showGameEventMonitor,
openSetupInstructions,
closeSetupInstructions,
autoSetupGameIntegration,
// device-discovery
onDeviceTypeChanged,
updateBaudFpsHint,
@@ -448,6 +472,7 @@ Object.assign(window, {
testNotification,
showNotificationHistory, closeNotificationHistory, refreshNotificationHistory,
testColorStrip, testCSPT, closeTestCssSourceModal, applyCssTestSettings, fireCssTestNotification, fireCssTestNotificationLayer,
addCSSGameMapping, removeCSSGameMapping, onCSSGameMappingPresetChange,
// audio sources
showAudioSourceModal,
@@ -99,3 +99,17 @@ export const droplets = '<path d="M7 16.3c2.2 0 4-1.83 4-4.05 0-1.16-.57-2.2
export const fan = '<path d="M10.827 16.379a6.082 6.082 0 0 1-8.618-7.002l5.412 1.45a6.082 6.082 0 0 1 7.002-8.618l-1.45 5.412a6.082 6.082 0 0 1 8.618 7.002l-5.412-1.45a6.082 6.082 0 0 1-7.002 8.618l1.45-5.412Z"/><path d="M12 12v.01"/>';
export const hardDrive = '<line x1="22" x2="2" y1="12" y2="12"/><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/><line x1="6" x2="6.01" y1="16" y2="16"/><line x1="10" x2="10.01" y1="16" y2="16"/>';
export const batteryFull = '<rect width="16" height="10" x="2" y="7" rx="2" ry="2"/><line x1="22" x2="22" y1="11" y2="13"/><line x1="6" x2="6" y1="11" y2="13"/><line x1="10" x2="10" y1="11" y2="13"/><line x1="14" x2="14" y1="11" y2="13"/>';
// Lucide: gamepad-2
export const gamepad2 = '<line x1="6" x2="10" y1="11" y2="11"/><line x1="8" x2="8" y1="9" y2="13"/><line x1="15" x2="15.01" y1="12" y2="12"/><line x1="18" x2="18.01" y1="10" y2="10"/><path d="M17.32 5H6.68a4 4 0 0 0-3.978 3.59c-.006.052-.01.101-.017.152C2.604 9.416 2 14.456 2 16a3 3 0 0 0 3 3c1 0 1.5-.5 2-1l1.414-1.414A2 2 0 0 1 9.828 16h4.344a2 2 0 0 1 1.414.586L17 18c.5.5 1 1 2 1a3 3 0 0 0 3-3c0-1.545-.604-6.584-.685-7.258-.007-.05-.011-.1-.017-.151A4 4 0 0 0 17.32 5z"/>';
// Lucide: crosshair
export const crosshair = '<circle cx="12" cy="12" r="10"/><line x1="22" x2="18" y1="12" y2="12"/><line x1="6" x2="2" y1="12" y2="12"/><line x1="12" x2="12" y1="6" y2="2"/><line x1="12" x2="12" y1="22" y2="18"/>';
// Lucide: swords
export const swords = '<polyline points="14.5 17.5 3 6 3 3 6 3 17.5 14.5"/><line x1="13" x2="19" y1="19" y2="13"/><line x1="16" x2="20" y1="16" y2="20"/><line x1="19" x2="21" y1="21" y2="19"/><polyline points="14.5 6.5 18 3 21 3 21 6 17.5 9.5"/><line x1="5" x2="9" y1="14" y2="18"/><line x1="7" x2="4" y1="17" y2="20"/><line x1="3" x2="5" y1="19" y2="21"/>';
// Lucide: shield
export const shield = '<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/>';
// Lucide: pickaxe (Minecraft-style)
export const pickaxe = '<path d="M14.531 12.469 6.619 20.38a1 1 0 1 1-3-3l7.912-7.912"/><path d="M15.686 4.314A12.5 12.5 0 0 0 5.461 2.958 1 1 0 0 0 5.58 4.71a22 22 0 0 1 6.318 3.393"/><path d="M17.7 3.7a1 1 0 0 0-1.4 0l-4.6 4.6a1 1 0 0 0 0 1.4l2.6 2.6a1 1 0 0 0 1.4 0l4.6-4.6a1 1 0 0 0 0-1.4z"/><path d="M19.686 8.314a12.501 12.501 0 0 1 1.356 10.225 1 1 0 0 1-1.751-.119 22 22 0 0 0-3.393-6.318"/>';
// Lucide: rocket
export const rocketIcon = '<path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z"/><path d="m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z"/><path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0"/><path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5"/>';
// Lucide: circle-dot (status indicator)
export const circleDot = '<circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="1"/>';
@@ -29,6 +29,7 @@ const _colorStripTypeIcons = {
weather: _svg(P.cloudSun),
processed: _svg(P.sparkles),
key_colors: _svg(P.palette),
game_event: _svg(P.gamepad2),
};
const _valueSourceTypeIcons = {
static: _svg(P.layoutDashboard), animated: _svg(P.refreshCw), audio: _svg(P.music),
@@ -39,6 +40,7 @@ const _valueSourceTypeIcons = {
ha_entity: _svg(P.home), gradient_map: _svg(P.rainbow),
css_extract: _svg(P.droplets),
system_metrics: _svg(P.cpu),
game_event: _svg(P.gamepad2),
};
const _audioSourceTypeIcons = { mono: _svg(P.mic), multichannel: _svg(P.volume2), band_extract: _svg(P.activity) };
const _deviceTypeIcons = {
@@ -333,6 +335,31 @@ export const ICON_ASSET = _svg(P.packageIcon);
export const ICON_HEART = _svg(P.heart);
export const ICON_GITHUB = _svg(P.github);
// ── Game integration icons ─────────────────────────────────
export const ICON_GAMEPAD = _svg(P.gamepad2);
export const ICON_CROSSHAIR = _svg(P.crosshair);
export const ICON_SWORDS = _svg(P.swords);
export const ICON_SHIELD = _svg(P.shield);
export const ICON_PICKAXE = _svg(P.pickaxe);
export const ICON_ROCKET_ICON = _svg(P.rocketIcon);
export const ICON_CIRCLE_DOT = _svg(P.circleDot);
/** Game adapter type → icon (fallback: gamepad) */
const _gameAdapterTypeIcons: Record<string, string> = {
cs2_gsi: _svg(P.crosshair),
valorant: _svg(P.crosshair),
lol_live: _svg(P.swords),
dota2_gsi: _svg(P.swords),
minecraft: _svg(P.pickaxe),
rocket_league: _svg(P.rocketIcon),
generic_webhook: _svg(P.globe),
};
export function getGameAdapterIcon(adapterType: string): string {
return _gameAdapterTypeIcons[adapterType] || _svg(P.gamepad2);
}
/** Asset type → icon (fallback: file) */
export function getAssetTypeIcon(assetType: string): string {
const map: Record<string, string> = {
@@ -13,6 +13,7 @@ import type {
SyncClock, WeatherSource, HomeAssistantSource, Asset, Automation, Display, FilterDef, EngineInfo,
CaptureTemplate, PostprocessingTemplate, AudioTemplate,
ColorStripProcessingTemplate, FilterInstance, KeyColorRectangle,
GameIntegration, GameAdapterInfo,
} from '../types.ts';
export let apiKey: string | null = null;
@@ -348,3 +349,19 @@ export const gradientsCache = new DataCache<GradientEntity[]>({
endpoint: '/gradients',
extractData: json => json.gradients || [],
});
// ── Game Integration caches ──
export let _cachedGameIntegrations: GameIntegration[] = [];
export const gameIntegrationsCache = new DataCache<GameIntegration[]>({
endpoint: '/game-integrations',
extractData: json => json.integrations || [],
});
gameIntegrationsCache.subscribe(v => { _cachedGameIntegrations = v; });
export let _cachedGameAdapters: GameAdapterInfo[] = [];
export const gameAdaptersCache = new DataCache<GameAdapterInfo[]>({
endpoint: '/game-adapters',
extractData: json => json.adapters || [],
});
gameAdaptersCache.subscribe(v => { _cachedGameAdapters = v; });
@@ -1,5 +1,5 @@
/**
* Automations automation cards, editor, condition builder, process picker, scene selector.
* Automations automation cards, editor, rule builder, process picker, scene selector.
*/
import { apiKey, _automationsLoading, set_automationsLoading, automationsCacheObj, scenePresetsCache, _cachedHASources } from '../core/state.ts';
@@ -22,30 +22,33 @@ import { TreeNav } from '../core/tree-nav.ts';
import { csScenes, createSceneCard, initScenePresetDelegation } from './scene-presets.ts';
import type { Automation } from '../types.ts';
// ── HA condition entity cache ──
let _haConditionEntities: any[] = [];
// ── HA rule entity cache ──
let _haRuleEntities: any[] = [];
async function _loadHAEntitiesForCondition(haSourceId: string, container: HTMLElement): Promise<void> {
if (!haSourceId) { _haConditionEntities = []; return; }
async function _loadHAEntitiesForRule(haSourceId: string, container: HTMLElement): Promise<void> {
if (!haSourceId) { _haRuleEntities = []; return; }
try {
const resp = await fetchWithAuth(`/home-assistant/sources/${haSourceId}/entities`);
if (!resp.ok) { _haConditionEntities = []; return; }
if (!resp.ok) { _haRuleEntities = []; return; }
const data = await resp.json();
_haConditionEntities = data.entities || [];
_haRuleEntities = data.entities || [];
} catch {
_haConditionEntities = [];
_haRuleEntities = [];
}
// Rebuild entity select options
const entitySelect = container.querySelector('.condition-ha-entity-id') as HTMLSelectElement;
const entitySelect = container.querySelector('.rule-ha-entity-id') as HTMLSelectElement;
if (entitySelect) {
const currentVal = entitySelect.value;
entitySelect.innerHTML = `<option value="">—</option>` +
_haConditionEntities.map((e: any) =>
_haRuleEntities.map((e: any) =>
`<option value="${e.entity_id}" ${e.entity_id === currentVal ? 'selected' : ''}>${escapeHtml(e.friendly_name || e.entity_id)}</option>`
).join('');
if (currentVal && !_haConditionEntities.some((e: any) => e.entity_id === currentVal)) {
if (currentVal && !_haRuleEntities.some((e: any) => e.entity_id === currentVal)) {
entitySelect.innerHTML += `<option value="${escapeHtml(currentVal)}" selected>${escapeHtml(currentVal)}</option>`;
}
// Refresh the EntitySelect wrapper so the trigger shows the friendly name
const es = (entitySelect as any)._entitySelect as EntitySelect | undefined;
if (es) es.refresh();
}
}
@@ -61,12 +64,12 @@ function _autoGenerateAutomationName() {
const sceneSel = document.getElementById('automation-scene-id') as HTMLSelectElement | null;
const sceneName = sceneSel?.selectedOptions[0]?.textContent?.trim() || '';
const logic = (document.getElementById('automation-editor-logic') as HTMLSelectElement).value;
const condCount = document.querySelectorAll('#automation-conditions-list .condition-row').length;
const ruleCount = document.querySelectorAll('#automation-rules-list .rule-row').length;
let name = '';
if (sceneName) name = sceneName;
if (condCount > 0) {
if (ruleCount > 0) {
const logicLabel = logic === 'and' ? 'AND' : 'OR';
const suffix = `${condCount} ${logicLabel}`;
const suffix = `${ruleCount} ${logicLabel}`;
name = name ? `${name} · ${suffix}` : suffix;
}
(document.getElementById('automation-editor-name') as HTMLInputElement).value = name || t('automations.add');
@@ -84,7 +87,7 @@ class AutomationEditorModal extends Modal {
name: (document.getElementById('automation-editor-name') as HTMLInputElement).value,
enabled: (document.getElementById('automation-editor-enabled') as HTMLInputElement).checked.toString(),
logic: (document.getElementById('automation-editor-logic') as HTMLSelectElement).value,
conditions: JSON.stringify(getAutomationEditorConditions()),
rules: JSON.stringify(getAutomationEditorRules()),
scenePresetId: (document.getElementById('automation-scene-id') as HTMLSelectElement).value,
deactivationMode: (document.getElementById('automation-deactivation-mode') as HTMLSelectElement).value,
deactivationScenePresetId: (document.getElementById('automation-fallback-scene-id') as HTMLSelectElement).value,
@@ -162,17 +165,17 @@ export function switchAutomationTab(tabKey: string) {
const _icon = (d: any) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
let _conditionLogicIconSelect: any = null;
let _ruleLogicIconSelect: any = null;
function _ensureConditionLogicIconSelect() {
function _ensureRuleLogicIconSelect() {
const sel = document.getElementById('automation-editor-logic');
if (!sel) return;
const items = [
{ value: 'or', icon: _icon(P.zap), label: t('automations.condition_logic.or'), desc: t('automations.condition_logic.or.desc') },
{ value: 'and', icon: _icon(P.link), label: t('automations.condition_logic.and'), desc: t('automations.condition_logic.and.desc') },
{ value: 'or', icon: _icon(P.zap), label: t('automations.rule_logic.or'), desc: t('automations.rule_logic.or.desc') },
{ value: 'and', icon: _icon(P.link), label: t('automations.rule_logic.and'), desc: t('automations.rule_logic.and.desc') },
];
if (_conditionLogicIconSelect) { _conditionLogicIconSelect.updateItems(items); return; }
_conditionLogicIconSelect = new IconSelect({ target: sel, items, columns: 2 } as any);
if (_ruleLogicIconSelect) { _ruleLogicIconSelect.updateItems(items); return; }
_ruleLogicIconSelect = new IconSelect({ target: sel, items, columns: 2 } as any);
}
// Re-render automations when language changes (only if tab is active)
@@ -255,44 +258,43 @@ function renderAutomations(automations: any, sceneMap: any) {
}
}
type ConditionPillRenderer = (c: any) => string;
type RulePillRenderer = (c: any) => string;
const CONDITION_PILL_RENDERERS: Record<string, ConditionPillRenderer> = {
always: (c) => `<span class="stream-card-prop">${ICON_OK} ${t('automations.condition.always')}</span>`,
startup: (c) => `<span class="stream-card-prop">${ICON_START} ${t('automations.condition.startup')}</span>`,
const RULE_PILL_RENDERERS: Record<string, RulePillRenderer> = {
startup: (c) => `<span class="stream-card-prop">${ICON_START} ${t('automations.rule.startup')}</span>`,
application: (c) => {
const apps = (c.apps || []).join(', ');
const matchLabel = t('automations.condition.application.match_type.' + (c.match_type || 'running'));
return `<span class="stream-card-prop stream-card-prop-full">${t('automations.condition.application')}: ${apps} (${matchLabel})</span>`;
const matchLabel = t('automations.rule.application.match_type.' + (c.match_type || 'running'));
return `<span class="stream-card-prop stream-card-prop-full">${t('automations.rule.application')}: ${apps} (${matchLabel})</span>`;
},
time_of_day: (c) => `<span class="stream-card-prop">${ICON_CLOCK} ${t('automations.condition.time_of_day')}: ${c.start_time || '00:00'} ${c.end_time || '23:59'}</span>`,
time_of_day: (c) => `<span class="stream-card-prop">${ICON_CLOCK} ${t('automations.rule.time_of_day')}: ${c.start_time || '00:00'} ${c.end_time || '23:59'}</span>`,
system_idle: (c) => {
const mode = c.when_idle !== false ? t('automations.condition.system_idle.when_idle') : t('automations.condition.system_idle.when_active');
const mode = c.when_idle !== false ? t('automations.rule.system_idle.when_idle') : t('automations.rule.system_idle.when_active');
return `<span class="stream-card-prop">${ICON_TIMER} ${c.idle_minutes || 5}m (${mode})</span>`;
},
display_state: (c) => {
const stateLabel = t('automations.condition.display_state.' + (c.state || 'on'));
return `<span class="stream-card-prop">${ICON_MONITOR} ${t('automations.condition.display_state')}: ${stateLabel}</span>`;
const stateLabel = t('automations.rule.display_state.' + (c.state || 'on'));
return `<span class="stream-card-prop">${ICON_MONITOR} ${t('automations.rule.display_state')}: ${stateLabel}</span>`;
},
mqtt: (c) => `<span class="stream-card-prop stream-card-prop-full">${ICON_RADIO} ${t('automations.condition.mqtt')}: ${escapeHtml(c.topic || '')} = ${escapeHtml(c.payload || '*')}</span>`,
webhook: (c) => `<span class="stream-card-prop">${ICON_WEB} ${t('automations.condition.webhook')}</span>`,
home_assistant: (c) => `<span class="stream-card-prop stream-card-prop-full">${_icon(P.home)} ${t('automations.condition.home_assistant')}: ${escapeHtml(c.entity_id || '')} = ${escapeHtml(c.state || '*')}</span>`,
mqtt: (c) => `<span class="stream-card-prop stream-card-prop-full">${ICON_RADIO} ${t('automations.rule.mqtt')}: ${escapeHtml(c.topic || '')} = ${escapeHtml(c.payload || '*')}</span>`,
webhook: (c) => `<span class="stream-card-prop">${ICON_WEB} ${t('automations.rule.webhook')}</span>`,
home_assistant: (c) => `<span class="stream-card-prop stream-card-prop-full">${_icon(P.home)} ${t('automations.rule.home_assistant')}: ${escapeHtml(c.entity_id || '')} = ${escapeHtml(c.state || '*')}</span>`,
};
function createAutomationCard(automation: Automation, sceneMap = new Map()) {
const statusClass = !automation.enabled ? 'disabled' : automation.is_active ? 'active' : 'inactive';
const statusText = !automation.enabled ? t('automations.status.disabled') : automation.is_active ? t('automations.status.active') : t('automations.status.inactive');
let condPills = '';
if (automation.conditions.length === 0) {
condPills = `<span class="stream-card-prop">${t('automations.conditions.empty')}</span>`;
let rulePills = '';
if (automation.rules.length === 0) {
rulePills = `<span class="stream-card-prop">${t('automations.rules.empty')}</span>`;
} else {
const parts = automation.conditions.map(c => {
const renderer = CONDITION_PILL_RENDERERS[c.condition_type];
return renderer ? renderer(c) : `<span class="stream-card-prop">${c.condition_type}</span>`;
const parts = automation.rules.map(c => {
const renderer = RULE_PILL_RENDERERS[c.rule_type];
return renderer ? renderer(c) : `<span class="stream-card-prop">${c.rule_type}</span>`;
});
const logicLabel = automation.condition_logic === 'and' ? t('automations.logic.and') : t('automations.logic.or');
condPills = parts.join(`<span class="automation-logic-label">${logicLabel}</span>`);
const logicLabel = automation.rule_logic === 'and' ? t('automations.logic.and') : t('automations.logic.or');
rulePills = parts.join(`<span class="automation-logic-label">${logicLabel}</span>`);
}
// Scene info
@@ -334,7 +336,7 @@ function createAutomationCard(automation: Automation, sceneMap = new Map()) {
</div>
</div>
<div class="card-subtitle">
<span class="card-meta">${condPills}</span>
<span class="card-meta">${rulePills}</span>
<span class="card-meta${scene ? ' stream-card-link' : ''}"${scene ? ` onclick="event.stopPropagation(); navigateToCard('automations',null,'scenes','data-scene-id','${automation.scene_preset_id}')"` : ''}>${ICON_SCENE} <span style="color:${sceneColor}">&#x25CF;</span> ${sceneName}</span>
${deactivationMeta}
</div>
@@ -355,13 +357,13 @@ export async function openAutomationEditor(automationId?: any, cloneData?: any)
const nameInput = document.getElementById('automation-editor-name') as HTMLInputElement;
const enabledInput = document.getElementById('automation-editor-enabled') as HTMLInputElement;
const logicSelect = document.getElementById('automation-editor-logic') as HTMLSelectElement;
const condList = document.getElementById('automation-conditions-list');
const ruleList = document.getElementById('automation-rules-list');
const errorEl = document.getElementById('automation-editor-error') as HTMLElement;
errorEl.style.display = 'none';
condList!.innerHTML = '';
ruleList!.innerHTML = '';
_ensureConditionLogicIconSelect();
_ensureRuleLogicIconSelect();
_ensureDeactivationModeIconSelect();
// Fetch scenes for selector
@@ -386,11 +388,11 @@ export async function openAutomationEditor(automationId?: any, cloneData?: any)
idInput.value = automation.id;
nameInput.value = automation.name;
enabledInput.checked = automation.enabled;
logicSelect.value = automation.condition_logic;
if (_conditionLogicIconSelect) _conditionLogicIconSelect.setValue(automation.condition_logic);
logicSelect.value = automation.rule_logic;
if (_ruleLogicIconSelect) _ruleLogicIconSelect.setValue(automation.rule_logic);
for (const c of automation.conditions) {
addAutomationConditionRow(c);
for (const c of automation.rules) {
addAutomationRuleRow(c);
}
// Scene selector
@@ -413,14 +415,14 @@ export async function openAutomationEditor(automationId?: any, cloneData?: any)
idInput.value = '';
nameInput.value = (cloneData.name || '') + ' (Copy)';
enabledInput.checked = cloneData.enabled !== false;
logicSelect.value = cloneData.condition_logic || 'or';
if (_conditionLogicIconSelect) _conditionLogicIconSelect.setValue(cloneData.condition_logic || 'or');
logicSelect.value = cloneData.rule_logic || 'or';
if (_ruleLogicIconSelect) _ruleLogicIconSelect.setValue(cloneData.rule_logic || 'or');
// Clone conditions (strip webhook tokens — they must be unique)
for (const c of (cloneData.conditions || [])) {
// Clone rules (strip webhook tokens — they must be unique)
for (const c of (cloneData.rules || [])) {
const clonedCond = { ...c };
if (clonedCond.condition_type === 'webhook') delete clonedCond.token;
addAutomationConditionRow(clonedCond);
if (clonedCond.rule_type === 'webhook') delete clonedCond.token;
addAutomationRuleRow(clonedCond);
}
_initSceneSelector('automation-scene-id', cloneData.scene_preset_id);
@@ -437,7 +439,7 @@ export async function openAutomationEditor(automationId?: any, cloneData?: any)
nameInput.value = '';
enabledInput.checked = true;
logicSelect.value = 'or';
if (_conditionLogicIconSelect) _conditionLogicIconSelect.setValue('or');
if (_ruleLogicIconSelect) _ruleLogicIconSelect.setValue('or');
_initSceneSelector('automation-scene-id', null);
_initSceneSelector('automation-fallback-scene-id', null);
}
@@ -539,14 +541,14 @@ function _ensureDeactivationModeIconSelect() {
// ===== Condition editor =====
export function addAutomationCondition() {
addAutomationConditionRow({ condition_type: 'application', apps: [], match_type: 'running' });
export function addAutomationRule() {
addAutomationRuleRow({ rule_type: 'application', apps: [], match_type: 'running' });
_autoGenerateAutomationName();
}
const CONDITION_TYPE_KEYS = ['always', 'startup', 'application', 'time_of_day', 'system_idle', 'display_state', 'mqtt', 'webhook', 'home_assistant'];
const CONDITION_TYPE_ICONS = {
always: P.refreshCw, startup: P.power, application: P.smartphone,
const RULE_TYPE_KEYS = ['startup', 'application', 'time_of_day', 'system_idle', 'display_state', 'mqtt', 'webhook', 'home_assistant'];
const RULE_TYPE_ICONS = {
startup: P.power, application: P.smartphone,
time_of_day: P.clock, system_idle: P.moon, display_state: P.monitor,
mqtt: P.radio, webhook: P.globe, home_assistant: P.home,
};
@@ -560,17 +562,17 @@ function _buildMatchTypeItems() {
return MATCH_TYPE_KEYS.map(k => ({
value: k,
icon: _icon(MATCH_TYPE_ICONS[k]),
label: t(`automations.condition.application.match_type.${k}`),
desc: t(`automations.condition.application.match_type.${k}.desc`),
label: t(`automations.rule.application.match_type.${k}`),
desc: t(`automations.rule.application.match_type.${k}.desc`),
}));
}
function _buildConditionTypeItems() {
return CONDITION_TYPE_KEYS.map(k => ({
function _buildRuleTypeItems() {
return RULE_TYPE_KEYS.map(k => ({
value: k,
icon: _icon(CONDITION_TYPE_ICONS[k]),
label: t(`automations.condition.${k}`),
desc: t(`automations.condition.${k}.desc`),
icon: _icon(RULE_TYPE_ICONS[k]),
label: t(`automations.rule.${k}`),
desc: t(`automations.rule.${k}.desc`),
}));
}
@@ -580,8 +582,8 @@ function _wireTimeRangePicker(container: HTMLElement) {
const startM = container.querySelector('.tr-start-m') as HTMLInputElement;
const endH = container.querySelector('.tr-end-h') as HTMLInputElement;
const endM = container.querySelector('.tr-end-m') as HTMLInputElement;
const hiddenStart = container.querySelector('.condition-start-time') as HTMLInputElement;
const hiddenEnd = container.querySelector('.condition-end-time') as HTMLInputElement;
const hiddenStart = container.querySelector('.rule-start-time') as HTMLInputElement;
const hiddenEnd = container.querySelector('.rule-end-time') as HTMLInputElement;
if (!startH || !startM || !endH || !endM) return;
const pad = (n: number) => String(n).padStart(2, '0');
@@ -628,39 +630,35 @@ function _wireTimeRangePicker(container: HTMLElement) {
sync();
}
function addAutomationConditionRow(condition: any) {
const list = document.getElementById('automation-conditions-list');
function addAutomationRuleRow(rule: any) {
const list = document.getElementById('automation-rules-list');
const row = document.createElement('div');
row.className = 'automation-condition-row';
const condType = condition.condition_type || 'application';
row.className = 'automation-rule-row';
const ruleType = rule.rule_type || 'application';
row.innerHTML = `
<div class="condition-header">
<select class="condition-type-select">
${CONDITION_TYPE_KEYS.map(k => `<option value="${k}" ${condType === k ? 'selected' : ''}>${t('automations.condition.' + k)}</option>`).join('')}
<div class="rule-header">
<select class="rule-type-select">
${RULE_TYPE_KEYS.map(k => `<option value="${k}" ${ruleType === k ? 'selected' : ''}>${t('automations.rule.' + k)}</option>`).join('')}
</select>
<button type="button" class="btn-remove-condition" onclick="this.closest('.automation-condition-row').remove(); if(window._autoGenerateAutomationName) window._autoGenerateAutomationName();" title="Remove">${ICON_TRASH}</button>
<button type="button" class="btn-remove-rule" onclick="this.closest('.automation-rule-row').remove(); if(window._autoGenerateAutomationName) window._autoGenerateAutomationName();" title="Remove">${ICON_TRASH}</button>
</div>
<div class="condition-fields-container"></div>
<div class="rule-fields-container"></div>
`;
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 = `<small class="condition-always-desc">${t('automations.condition.always.hint')}</small>`;
return;
}
if (type === 'startup') {
container.innerHTML = `<small class="condition-always-desc">${t('automations.condition.startup.hint')}</small>`;
container.innerHTML = `<small class="rule-hint-desc">${t('automations.rule.startup.hint')}</small>`;
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 = `
<div class="condition-fields">
<input type="hidden" class="condition-start-time" value="${startTime}">
<input type="hidden" class="condition-end-time" value="${endTime}">
<div class="rule-fields">
<input type="hidden" class="rule-start-time" value="${startTime}">
<input type="hidden" class="rule-end-time" value="${endTime}">
<div class="time-range-picker">
<div class="time-range-slot">
<span class="time-range-label">${t('automations.condition.time_of_day.start_time')}</span>
<span class="time-range-label">${t('automations.rule.time_of_day.start_time')}</span>
<div class="time-range-input-wrap">
<input type="number" class="tr-start-h" min="0" max="23" value="${sh}" data-role="hour">
<span class="time-range-colon">:</span>
@@ -684,7 +682,7 @@ function addAutomationConditionRow(condition: any) {
</div>
<div class="time-range-arrow"></div>
<div class="time-range-slot">
<span class="time-range-label">${t('automations.condition.time_of_day.end_time')}</span>
<span class="time-range-label">${t('automations.rule.time_of_day.end_time')}</span>
<div class="time-range-input-wrap">
<input type="number" class="tr-end-h" min="0" max="23" value="${eh}" data-role="hour">
<span class="time-range-colon">:</span>
@@ -692,7 +690,7 @@ function addAutomationConditionRow(condition: any) {
</div>
</div>
</div>
<small class="condition-always-desc">${t('automations.condition.time_of_day.overnight_hint')}</small>
<small class="rule-hint-desc">${t('automations.rule.time_of_day.overnight_hint')}</small>
</div>`;
_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 = `
<div class="condition-fields">
<div class="condition-field">
<label>${t('automations.condition.system_idle.idle_minutes')}</label>
<input type="number" class="condition-idle-minutes" min="1" max="999" value="${idleMinutes}">
<div class="rule-fields">
<div class="rule-field">
<label>${t('automations.rule.system_idle.idle_minutes')}</label>
<input type="number" class="rule-idle-minutes" min="1" max="999" value="${idleMinutes}">
</div>
<div class="condition-field">
<label>${t('automations.condition.system_idle.mode')}</label>
<select class="condition-when-idle">
<option value="true" ${whenIdle ? 'selected' : ''}>${t('automations.condition.system_idle.when_idle')}</option>
<option value="false" ${!whenIdle ? 'selected' : ''}>${t('automations.condition.system_idle.when_active')}</option>
<div class="rule-field">
<label>${t('automations.rule.system_idle.mode')}</label>
<select class="rule-when-idle">
<option value="true" ${whenIdle ? 'selected' : ''}>${t('automations.rule.system_idle.when_idle')}</option>
<option value="false" ${!whenIdle ? 'selected' : ''}>${t('automations.rule.system_idle.when_active')}</option>
</select>
</div>
</div>`;
@@ -719,12 +717,12 @@ function addAutomationConditionRow(condition: any) {
if (type === 'display_state') {
const dState = data.state || 'on';
container.innerHTML = `
<div class="condition-fields">
<div class="condition-field">
<label>${t('automations.condition.display_state.state')}</label>
<select class="condition-display-state">
<option value="on" ${dState === 'on' ? 'selected' : ''}>${t('automations.condition.display_state.on')}</option>
<option value="off" ${dState === 'off' ? 'selected' : ''}>${t('automations.condition.display_state.off')}</option>
<div class="rule-fields">
<div class="rule-field">
<label>${t('automations.rule.display_state.state')}</label>
<select class="rule-display-state">
<option value="on" ${dState === 'on' ? 'selected' : ''}>${t('automations.rule.display_state.on')}</option>
<option value="off" ${dState === 'off' ? 'selected' : ''}>${t('automations.rule.display_state.off')}</option>
</select>
</div>
</div>`;
@@ -735,21 +733,21 @@ function addAutomationConditionRow(condition: any) {
const payload = data.payload || '';
const matchMode = data.match_mode || 'exact';
container.innerHTML = `
<div class="condition-fields">
<div class="condition-field">
<label>${t('automations.condition.mqtt.topic')}</label>
<input type="text" class="condition-mqtt-topic" value="${escapeHtml(topic)}" placeholder="home/status/power">
<div class="rule-fields">
<div class="rule-field">
<label>${t('automations.rule.mqtt.topic')}</label>
<input type="text" class="rule-mqtt-topic" value="${escapeHtml(topic)}" placeholder="home/status/power">
</div>
<div class="condition-field">
<label>${t('automations.condition.mqtt.payload')}</label>
<input type="text" class="condition-mqtt-payload" value="${escapeHtml(payload)}" placeholder="ON">
<div class="rule-field">
<label>${t('automations.rule.mqtt.payload')}</label>
<input type="text" class="rule-mqtt-payload" value="${escapeHtml(payload)}" placeholder="ON">
</div>
<div class="condition-field">
<label>${t('automations.condition.mqtt.match_mode')}</label>
<select class="condition-mqtt-match-mode">
<option value="exact" ${matchMode === 'exact' ? 'selected' : ''}>${t('automations.condition.mqtt.match_mode.exact')}</option>
<option value="contains" ${matchMode === 'contains' ? 'selected' : ''}>${t('automations.condition.mqtt.match_mode.contains')}</option>
<option value="regex" ${matchMode === 'regex' ? 'selected' : ''}>${t('automations.condition.mqtt.match_mode.regex')}</option>
<div class="rule-field">
<label>${t('automations.rule.mqtt.match_mode')}</label>
<select class="rule-mqtt-match-mode">
<option value="exact" ${matchMode === 'exact' ? 'selected' : ''}>${t('automations.rule.mqtt.match_mode.exact')}</option>
<option value="contains" ${matchMode === 'contains' ? 'selected' : ''}>${t('automations.rule.mqtt.match_mode.contains')}</option>
<option value="regex" ${matchMode === 'regex' ? 'selected' : ''}>${t('automations.rule.mqtt.match_mode.regex')}</option>
</select>
</div>
</div>`;
@@ -764,37 +762,37 @@ function addAutomationConditionRow(condition: any) {
`<option value="${s.id}" ${s.id === haSourceId ? 'selected' : ''}>${escapeHtml(s.name)}</option>`
).join('');
container.innerHTML = `
<div class="condition-fields">
<small class="condition-always-desc">${t('automations.condition.home_assistant.hint')}</small>
<div class="condition-field">
<label>${t('automations.condition.home_assistant.ha_source')}</label>
<select class="condition-ha-source-id">
<div class="rule-fields">
<small class="rule-hint-desc">${t('automations.rule.home_assistant.hint')}</small>
<div class="rule-field">
<label>${t('automations.rule.home_assistant.ha_source')}</label>
<select class="rule-ha-source-id">
<option value=""></option>
${haOptions}
</select>
</div>
<div class="condition-field">
<label>${t('automations.condition.home_assistant.entity_id')}</label>
<select class="condition-ha-entity-id">
<div class="rule-field">
<label>${t('automations.rule.home_assistant.entity_id')}</label>
<select class="rule-ha-entity-id">
${entityId ? `<option value="${escapeHtml(entityId)}" selected>${escapeHtml(entityId)}</option>` : '<option value="">—</option>'}
</select>
</div>
<div class="condition-field">
<label>${t('automations.condition.home_assistant.state')}</label>
<input type="text" class="condition-ha-state" value="${escapeHtml(haState)}" placeholder="on">
<div class="rule-field">
<label>${t('automations.rule.home_assistant.state')}</label>
<input type="text" class="rule-ha-state" value="${escapeHtml(haState)}" placeholder="on">
</div>
<div class="condition-field">
<label>${t('automations.condition.home_assistant.match_mode')}</label>
<select class="condition-ha-match-mode">
<option value="exact" ${matchMode === 'exact' ? 'selected' : ''}>${t('automations.condition.mqtt.match_mode.exact')}</option>
<option value="contains" ${matchMode === 'contains' ? 'selected' : ''}>${t('automations.condition.mqtt.match_mode.contains')}</option>
<option value="regex" ${matchMode === 'regex' ? 'selected' : ''}>${t('automations.condition.mqtt.match_mode.regex')}</option>
<div class="rule-field">
<label>${t('automations.rule.home_assistant.match_mode')}</label>
<select class="rule-ha-match-mode">
<option value="exact" ${matchMode === 'exact' ? 'selected' : ''}>${t('automations.rule.mqtt.match_mode.exact')}</option>
<option value="contains" ${matchMode === 'contains' ? 'selected' : ''}>${t('automations.rule.mqtt.match_mode.contains')}</option>
<option value="regex" ${matchMode === 'regex' ? 'selected' : ''}>${t('automations.rule.mqtt.match_mode.regex')}</option>
</select>
</div>
</div>`;
// 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 = `
<div class="condition-fields">
<small class="condition-always-desc">${t('automations.condition.webhook.hint')}</small>
<div class="condition-field">
<label>${t('automations.condition.webhook.url')}</label>
<div class="rule-fields">
<small class="rule-hint-desc">${t('automations.rule.webhook.hint')}</small>
<div class="rule-field">
<label>${t('automations.rule.webhook.url')}</label>
<div class="webhook-url-row">
<input type="text" class="condition-webhook-url" value="${escapeHtml(webhookUrl)}" readonly>
<button type="button" class="btn btn-secondary btn-webhook-copy" onclick="copyWebhookUrl(this)">${t('automations.condition.webhook.copy')}</button>
<input type="text" class="rule-webhook-url" value="${escapeHtml(webhookUrl)}" readonly>
<button type="button" class="btn btn-secondary btn-webhook-copy" onclick="copyWebhookUrl(this)">${t('automations.rule.webhook.copy')}</button>
</div>
</div>
<input type="hidden" class="condition-webhook-token" value="${escapeHtml(data.token)}">
<input type="hidden" class="rule-webhook-token" value="${escapeHtml(data.token)}">
</div>`;
} else {
container.innerHTML = `
<div class="condition-fields">
<small class="condition-always-desc">${t('automations.condition.webhook.hint')}</small>
<p class="webhook-save-hint">${t('automations.condition.webhook.save_first')}</p>
<div class="rule-fields">
<small class="rule-hint-desc">${t('automations.rule.webhook.hint')}</small>
<p class="webhook-save-hint">${t('automations.rule.webhook.save_first')}</p>
</div>`;
}
return;
@@ -860,30 +860,30 @@ function addAutomationConditionRow(condition: any) {
const appsValue = (data.apps || []).join('\n');
const matchType = data.match_type || 'running';
container.innerHTML = `
<div class="condition-fields">
<div class="condition-field">
<label>${t('automations.condition.application.match_type')}</label>
<select class="condition-match-type">
<option value="running" ${matchType === 'running' ? 'selected' : ''}>${t('automations.condition.application.match_type.running')}</option>
<option value="topmost" ${matchType === 'topmost' ? 'selected' : ''}>${t('automations.condition.application.match_type.topmost')}</option>
<option value="topmost_fullscreen" ${matchType === 'topmost_fullscreen' ? 'selected' : ''}>${t('automations.condition.application.match_type.topmost_fullscreen')}</option>
<option value="fullscreen" ${matchType === 'fullscreen' ? 'selected' : ''}>${t('automations.condition.application.match_type.fullscreen')}</option>
<div class="rule-fields">
<div class="rule-field">
<label>${t('automations.rule.application.match_type')}</label>
<select class="rule-match-type">
<option value="running" ${matchType === 'running' ? 'selected' : ''}>${t('automations.rule.application.match_type.running')}</option>
<option value="topmost" ${matchType === 'topmost' ? 'selected' : ''}>${t('automations.rule.application.match_type.topmost')}</option>
<option value="topmost_fullscreen" ${matchType === 'topmost_fullscreen' ? 'selected' : ''}>${t('automations.rule.application.match_type.topmost_fullscreen')}</option>
<option value="fullscreen" ${matchType === 'fullscreen' ? 'selected' : ''}>${t('automations.rule.application.match_type.fullscreen')}</option>
</select>
</div>
<div class="condition-field">
<div class="condition-apps-header">
<label>${t('automations.condition.application.apps')}</label>
<button type="button" class="btn btn-icon btn-secondary btn-browse-apps" title="${t('automations.condition.application.browse')}">${ICON_SEARCH}</button>
<div class="rule-field">
<div class="rule-apps-header">
<label>${t('automations.rule.application.apps')}</label>
<button type="button" class="btn btn-icon btn-secondary btn-browse-apps" title="${t('automations.rule.application.browse')}">${ICON_SEARCH}</button>
</div>
<textarea class="condition-apps" rows="3" placeholder="firefox.exe&#10;chrome.exe">${escapeHtml(appsValue)}</textarea>
<textarea class="rule-apps" rows="3" placeholder="firefox.exe&#10;chrome.exe">${escapeHtml(appsValue)}</textarea>
</div>
</div>
`;
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) {
@@ -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<string, string> = {
'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<string, () => 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 = `<option value="">${t('common.none_no_input')}</option>` +
integrations.map(gi => `<option value="${gi.id}"${gi.id === prev ? ' selected' : ''}>${escapeHtml(gi.name)}</option>`).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: `<svg class="icon" viewBox="0 0 24 24">${P.zap}</svg>` },
{ value: 'pulse', label: 'Pulse', icon: `<svg class="icon" viewBox="0 0 24 24">${P.activity}</svg>` },
{ value: 'sweep', label: 'Sweep', icon: `<svg class="icon" viewBox="0 0 24 24">${P.fastForward}</svg>` },
{ value: 'color_shift', label: 'Color Shift', icon: `<svg class="icon" viewBox="0 0 24 24">${P.rainbow}</svg>` },
{ value: 'breathing', label: 'Breathing', icon: `<svg class="icon" viewBox="0 0 24 24">${P.heart}</svg>` },
];
const _CSS_GE_EVENT_ICONS: Record<string, string> = {
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: `<svg class="icon" viewBox="0 0 24 24">${_CSS_GE_EVENT_ICONS[et] || P.circleDot}</svg>`,
}));
}
function _renderCSSGameMappingRow(mapping: any, index: number): string {
const eventTypes = _getCSSGameAvailableEventTypes();
const eventOptions = eventTypes.map(et =>
`<option value="${et}"${et === mapping.event_type ? ' selected' : ''}>${et}</option>`
).join('');
const effectOptions = _CSS_GE_EFFECT_TYPES.map(ef =>
`<option value="${ef.value}"${ef.value === mapping.effect_type ? ' selected' : ''}>${ef.label}</option>`
).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 `
<div class="gi-mapping-row" data-mapping-index="${index}">
<div class="gi-mapping-header">
<span class="gi-mapping-expand-btn">&#x25B6;</span>
<span class="gi-mapping-summary">
<span class="gi-mapping-summary-event">${escapeHtml(mapping.event_type)}</span>
<span class="gi-mapping-summary-effect">${escapeHtml(effectLabel)}</span>
<span class="gi-mapping-summary-color" style="background:${hexColor}"></span>
</span>
<button type="button" class="btn-remove-rule" onclick="event.stopPropagation(); removeCSSGameMapping(${index})" title="${t('common.delete')}">${ICON_TRASH}</button>
</div>
<div class="gi-mapping-body-wrapper">
<div class="gi-mapping-body">
<div class="gi-mapping-field-row">
<label>${t('game_integration.mapping.event_type')}</label>
<select data-field="event_type">${eventOptions}</select>
</div>
<div class="gi-mapping-field-row">
<label>${t('game_integration.mapping.effect_type')}</label>
<select data-field="effect_type">${effectOptions}</select>
</div>
<div class="gi-mapping-field-row">
<label>${t('game_integration.mapping.color')}</label>
<input type="color" data-field="color" value="${hexColor}">
</div>
<div class="gi-mapping-field-row">
<label>${t('game_integration.mapping.duration')}</label>
<input type="number" data-field="duration_ms" value="${mapping.duration_ms || 500}" min="50" max="10000" step="50">
</div>
<div class="gi-mapping-field-row">
<label>${t('game_integration.mapping.intensity')}</label>
<input type="range" data-field="intensity" value="${mapping.intensity ?? 1.0}" min="0" max="1" step="0.05"
oninput="this.title = this.value">
</div>
<div class="gi-mapping-field-row">
<label>${t('game_integration.mapping.priority')}</label>
<input type="number" data-field="priority" value="${mapping.priority || 5}" min="1" max="10">
</div>
</div>
</div>
</div>`;
}
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<string, any[]> = {
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: `<svg class="icon" viewBox="0 0 24 24">${P.crosshair}</svg>` },
{ value: 'moba_health', label: t('game_integration.preset.moba_health'), icon: `<svg class="icon" viewBox="0 0 24 24">${P.heart}</svg>` },
];
_cssGamePresetIconSelect = new IconSelect({ target: sel, items, columns: 2 });
}
function _ensureAudioSensitivityWidget(): BindableScalarWidget {
if (!_audioSensitivityWidget) {
_audioSensitivityWidget = new BindableScalarWidget({
@@ -2107,6 +2382,31 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => 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 ────────────────────────────────────────── */
@@ -649,18 +649,18 @@ function renderDashboardAutomation(automation: Automation, sceneMap: Map<string,
const isDisabled = !automation.enabled;
let condSummary = '';
if (automation.conditions.length > 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);
}
@@ -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) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
// ── 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<typeof setInterval> | 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<string, any> {
const container = document.getElementById('gi-adapter-config-fields');
if (!container) return {};
const config: Record<string, any> = {};
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<string, any> = {}) {
const container = document.getElementById('gi-adapter-config-fields')!;
if (!adapter.config_schema || adapter.config_schema.length === 0) {
container.innerHTML = `<p class="text-muted">${t('game_integration.no_config')}</p>`;
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 `
<div class="form-group">
<div class="label-row">
<label for="gi-config-${escapeHtml(field.name)}">${escapeHtml(field.label || field.name)}${field.required ? ' *' : ''}</label>
${field.hint ? `<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>` : ''}
</div>
${field.hint ? `<small class="input-hint" style="display:none">${escapeHtml(field.hint)}</small>` : ''}
<input type="${inputType}" id="gi-config-${escapeHtml(field.name)}"
data-config-key="${escapeHtml(field.name)}"${inputVal}${checked}>
</div>`;
}).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<string, string> = {
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 =>
`<option value="${et}"${et === mapping.event_type ? ' selected' : ''}>${et}</option>`
).join('');
const effectOptions = EFFECT_TYPES.map(ef =>
`<option value="${ef.value}"${ef.value === mapping.effect_type ? ' selected' : ''}>${ef.label}</option>`
).join('');
const effectLabel = EFFECT_TYPES.find(ef => ef.value === mapping.effect_type)?.label || mapping.effect_type;
const hexColor = _rgbToHex(mapping.color);
return `
<div class="gi-mapping-row" data-mapping-index="${index}">
<div class="gi-mapping-header">
<span class="gi-mapping-expand-btn">&#x25B6;</span>
<span class="gi-mapping-summary">
<span class="gi-mapping-summary-event">${escapeHtml(mapping.event_type)}</span>
<span class="gi-mapping-summary-effect">${escapeHtml(effectLabel)}</span>
<span class="gi-mapping-summary-color" style="background:${hexColor}"></span>
</span>
<button type="button" class="btn-remove-rule" onclick="event.stopPropagation(); removeGameMapping(${index})" title="${t('common.delete')}">${ICON_TRASH}</button>
</div>
<div class="gi-mapping-body-wrapper">
<div class="gi-mapping-body">
<div class="gi-mapping-field-row">
<label>${t('game_integration.mapping.event_type')}</label>
<select data-field="event_type">${eventOptions}</select>
</div>
<div class="gi-mapping-field-row">
<label>${t('game_integration.mapping.effect_type')}</label>
<select data-field="effect_type" id="gi-effect-type-${index}">${effectOptions}</select>
</div>
<div class="gi-mapping-field-row">
<label>${t('game_integration.mapping.color')}</label>
<input type="color" data-field="color" value="${hexColor}">
</div>
<div class="gi-mapping-field-row">
<label>${t('game_integration.mapping.duration')}</label>
<input type="number" data-field="duration_ms" value="${mapping.duration_ms}" min="50" max="10000" step="50">
</div>
<div class="gi-mapping-field-row">
<label>${t('game_integration.mapping.intensity')}</label>
<input type="range" data-field="intensity" value="${mapping.intensity}" min="0" max="1" step="0.05"
oninput="this.title = this.value">
</div>
<div class="gi-mapping-field-row">
<label>${t('game_integration.mapping.priority')}</label>
<input type="number" data-field="priority" value="${mapping.priority}" min="1" max="10">
</div>
</div>
</div>
</div>`;
}
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<EffectPreset[]> {
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 = `<option value="">${t('game_integration.mapping.select_preset')}</option>` +
presets.map(p => `<option value="${p.key}">${escapeHtml(p.name)}</option>`).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 = `<div class="gi-event-waiting">${t('game_integration.events.waiting')}</div>`;
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 `<div class="gi-event-item">
<span class="gi-event-time">${ts}</span>
<span class="gi-event-type">${escapeHtml(ev.event_type)}</span>
<span class="gi-event-value">${valStr}</span>
</div>`;
}).join('');
} catch { /* ignore polling errors */ }
};
poll();
_eventMonitorTimer = setInterval(poll, 2000);
}
// ── Connection test ──
let _connectionTestTimer: ReturnType<typeof setInterval> | 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 = `<div class="gi-test-waiting">${ICON_CIRCLE_DOT} ${t('game_integration.test.waiting')}</div>`;
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 = `<div class="gi-test-success">${t('game_integration.test.success')} (${status.event_count})</div>`;
} else if (status.error) {
clearInterval(_connectionTestTimer!);
_connectionTestTimer = null;
panel.innerHTML = `<div class="gi-test-error">${t('game_integration.test.error')}: ${escapeHtml(status.error)}</div>`;
}
} catch { /* ignore */ }
if (attempts >= 30) {
clearInterval(_connectionTestTimer!);
_connectionTestTimer = null;
panel.innerHTML = `<div class="gi-test-timeout">${t('game_integration.test.timeout')}</div>`;
}
}, 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: `
<div class="template-card-header">
<div class="template-name" title="${escapeHtml(gi.name)}">${adapterIcon} ${escapeHtml(gi.name)}</div>
</div>
${gi.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(gi.description)}</div>` : ''}
<div class="stream-card-props">
<span class="stream-card-prop" title="${t('game_integration.adapter')}">${ICON_GAMEPAD} ${escapeHtml(adapterName)}</span>
<span class="stream-card-prop ${enabledClass}" title="${t('game_integration.status')}">${ICON_CIRCLE_DOT} ${enabledLabel}</span>
${mappingCount > 0 ? `<span class="stream-card-prop" title="${t('game_integration.mappings')}">${_icon(P.listChecks)} ${mappingCount}</span>` : ''}
</div>
${renderTagChips(gi.tags)}`,
actions: `
<button class="btn btn-icon btn-secondary" onclick="event.stopPropagation(); showGameEventMonitor('${gi.id}')" title="${t('game_integration.events.monitor')}">${ICON_TEST}</button>
<button class="btn btn-icon btn-secondary" onclick="cloneGameIntegration('${gi.id}')" title="${t('common.clone')}">${ICON_CLONE}</button>
<button class="btn btn-icon btn-secondary" onclick="showGameIntegrationEditor('${gi.id}')" title="${t('common.edit')}">${ICON_EDIT}</button>`,
});
}
// ── 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 =>
`<option value="${a.adapter_type}">${escapeHtml(a.display_name)}</option>`
).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();
}
@@ -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: `<svg class="icon" viewBox="0 0 24 24">${P.cloudSun}</svg>`, titleKey: 'streams.group.weather', count: _cachedWeatherSources.length },
{ key: 'home_assistant', icon: `<svg class="icon" viewBox="0 0 24 24">${P.home}</svg>`, 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: `<svg class="icon" viewBox="0 0 24 24">${P.cloudSun}</svg>`, count: _cachedWeatherSources.length },
{ key: 'home_assistant', titleKey: 'streams.group.home_assistant', icon: `<svg class="icon" viewBox="0 0 24 24">${P.home}</svg>`, 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 `<div class="stream-tab-panel${tab.key === activeTab ? ' active' : ''}" id="stream-tab-${tab.key}">${panelContent}</div>`;
}).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',
});
}
}
@@ -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 = `<option value="">\u2014</option>` +
integrations.map(gi => `<option value="${gi.id}"${gi.id === prev ? ' selected' : ''}>${escapeHtml(gi.name)}</option>`).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 =>
`<option value="${et}"${et === prev ? ' selected' : ''}>${et}</option>`
).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 {
+19 -1
View File
@@ -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;
+98 -3
View File
@@ -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<string, any>;
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<string, any>;
}
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';
@@ -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."
}
@@ -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": "Секунды тишины до возврата к значению по умолчанию."
}
@@ -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": "恢复到默认值前的静默秒数。"
}
@@ -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"),
@@ -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
@@ -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,
}
@@ -56,6 +56,7 @@ _ENTITY_TABLES = [
"weather_sources",
"assets",
"home_assistant_sources",
"game_integrations",
]
@@ -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,
)
@@ -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 []
@@ -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,
}
@@ -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' %}
@@ -24,7 +24,7 @@
<label data-i18n="automations.enabled">Enabled:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="automations.enabled.hint">Disabled automations won't activate even when conditions are met</small>
<small class="input-hint" style="display:none" data-i18n="automations.enabled.hint">Disabled automations won't activate even when rules are met</small>
<label class="settings-toggle">
<input type="checkbox" id="automation-editor-enabled" checked>
<span class="settings-toggle-slider"></span>
@@ -33,25 +33,25 @@
<div class="form-group">
<div class="label-row">
<label for="automation-editor-logic" data-i18n="automations.condition_logic">Condition Logic:</label>
<label for="automation-editor-logic" data-i18n="automations.rule_logic">Rule Logic:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="automations.condition_logic.hint">How multiple conditions are combined: ANY (OR) or ALL (AND)</small>
<small class="input-hint" style="display:none" data-i18n="automations.rule_logic.hint">How multiple rules are combined: ANY (OR) or ALL (AND)</small>
<select id="automation-editor-logic">
<option value="or" data-i18n="automations.condition_logic.or">Any condition (OR)</option>
<option value="and" data-i18n="automations.condition_logic.and">All conditions (AND)</option>
<option value="or" data-i18n="automations.rule_logic.or">Any rule (OR)</option>
<option value="and" data-i18n="automations.rule_logic.and">All rules (AND)</option>
</select>
</div>
<div class="form-group">
<div class="label-row">
<label data-i18n="automations.conditions">Conditions:</label>
<label data-i18n="automations.rules">Rules:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="automations.conditions.hint">Rules that determine when this automation activates</small>
<div id="automation-conditions-list"></div>
<button type="button" class="btn btn-secondary btn-sm" onclick="addAutomationCondition()" style="margin-top: 6px;">
+ <span data-i18n="automations.conditions.add">Add Condition</span>
<small class="input-hint" style="display:none" data-i18n="automations.rules.hint">Rules that determine when this automation activates</small>
<div id="automation-rules-list"></div>
<button type="button" class="btn btn-secondary btn-sm" onclick="addAutomationRule()" style="margin-top: 6px;">
+ <span data-i18n="automations.rules.add">Add Rule</span>
</button>
</div>
@@ -60,7 +60,7 @@
<label for="automation-scene-id" data-i18n="automations.scene">Scene:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="automations.scene.hint">Scene preset to activate when conditions are met</small>
<small class="input-hint" style="display:none" data-i18n="automations.scene.hint">Scene preset to activate when rules are met</small>
<select id="automation-scene-id"></select>
</div>
@@ -69,7 +69,7 @@
<label for="automation-deactivation-mode" data-i18n="automations.deactivation_mode">Deactivation:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="automations.deactivation_mode.hint">What happens when conditions stop matching</small>
<small class="input-hint" style="display:none" data-i18n="automations.deactivation_mode.hint">What happens when rules stop matching</small>
<select id="automation-deactivation-mode">
<option value="none" data-i18n="automations.deactivation_mode.none">None — keep current state</option>
<option value="revert" data-i18n="automations.deactivation_mode.revert">Revert to previous state</option>
@@ -38,6 +38,7 @@
<option value="weather" data-i18n="color_strip.type.weather">Weather</option>
<option value="processed" data-i18n="color_strip.type.processed">Processed</option>
<option value="key_colors" data-i18n="color_strip.type.key_colors">Key Colors</option>
<option value="game_event" data-i18n="color_strip.type.game_event">Game Event</option>
</select>
</div>
@@ -689,6 +690,46 @@
</div>
</div>
<!-- Game Event section -->
<div id="css-editor-game-event-section" style="display:none">
<div class="form-group">
<div class="label-row">
<label for="css-editor-game-integration" data-i18n="color_strip.game_event.integration">Game Integration:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="color_strip.game_event.integration.hint">Select the game integration that provides events for this source.</small>
<select id="css-editor-game-integration">
<option value=""></option>
</select>
</div>
<div class="form-group">
<div class="label-row">
<label data-i18n="color_strip.game_event.idle_color">Idle Color:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="color_strip.game_event.idle_color.hint">LED color when no game events are active.</small>
<div id="css-editor-game-event-idle-color-container"></div>
</div>
<div class="form-group">
<div class="label-row">
<label data-i18n="color_strip.game_event.event_mappings">Event Mappings:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="color_strip.game_event.event_mappings.hint">Override or add event-to-effect mappings for this source. These supplement the integration-level mappings.</small>
<div class="gi-mapping-preset-row">
<select id="css-editor-ge-mapping-preset" onchange="onCSSGameMappingPresetChange()">
<option value="" data-i18n="game_integration.preset.select">Load preset...</option>
<option value="fps_combat" data-i18n="game_integration.preset.fps_combat">FPS Combat</option>
<option value="moba_health" data-i18n="game_integration.preset.moba_health">MOBA Health</option>
</select>
</div>
<div id="css-editor-ge-mappings-list" class="gi-mappings-container"></div>
<button type="button" class="btn btn-secondary btn-sm" onclick="addCSSGameMapping()" style="margin-top:6px">
<span data-i18n="game_integration.mapping.add">+ Add Mapping</span>
</button>
</div>
</div>
<!-- Shared LED count field -->
<div id="css-editor-led-count-group" class="form-group">
<div class="label-row">
@@ -0,0 +1,112 @@
<div id="game-integration-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="gi-title">
<div class="modal-content modal-lg">
<div class="modal-header">
<h2 id="gi-title" data-i18n="game_integration.add">Add Game Integration</h2>
<button class="modal-close-btn" onclick="closeGameIntegrationModal()" data-i18n-aria-label="aria.close">&times;</button>
</div>
<div class="modal-body">
<input type="hidden" id="gi-id">
<div id="gi-error" class="modal-error" style="display:none"></div>
<!-- Name + Tags -->
<div class="form-group">
<div class="label-row">
<label for="gi-name" data-i18n="game_integration.name">Name:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="game_integration.name.hint">A descriptive name for this game integration</small>
<input type="text" id="gi-name" required>
<div id="gi-tags-container"></div>
</div>
<!-- Description -->
<div class="form-group">
<div class="label-row">
<label for="gi-description" data-i18n="game_integration.description">Description:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="game_integration.description.hint">Optional description of what this integration does</small>
<input type="text" id="gi-description">
</div>
<!-- Enabled -->
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" id="gi-enabled" checked>
<span data-i18n="game_integration.enabled">Enabled</span>
</label>
</div>
<!-- Game / Adapter picker -->
<div class="form-group">
<div class="label-row">
<label for="gi-adapter-type" data-i18n="game_integration.adapter_type">Game / Adapter:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="game_integration.adapter_type.hint">Select the game or adapter type for this integration</small>
<select id="gi-adapter-type"></select>
</div>
<!-- Adapter config (auto-generated) -->
<div class="form-group">
<div class="label-row">
<label data-i18n="game_integration.adapter_config">Adapter Configuration</label>
</div>
<div id="gi-adapter-config-fields"></div>
</div>
<!-- Setup instructions + Auto Setup buttons -->
<div id="gi-setup-instructions-btn-wrapper" style="display:none">
<button type="button" class="btn btn-secondary btn-sm" onclick="openSetupInstructions()" data-i18n="game_integration.setup_instructions">Setup Instructions</button>
<button type="button" id="gi-auto-setup-btn" class="btn btn-primary btn-sm" onclick="autoSetupGameIntegration()" style="display:none" data-i18n="game_integration.auto_setup">Auto Setup</button>
</div>
<!-- Event Mapping Editor -->
<div class="form-group">
<div class="label-row">
<label data-i18n="game_integration.event_mappings">Event Mappings</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="game_integration.event_mappings.hint">Map game events to LED effects. Each event type can trigger a different visual effect.</small>
<div class="gi-mapping-toolbar">
<select id="gi-mapping-preset" onchange="onMappingPresetChange()">
<option value="" data-i18n="game_integration.preset.select">Load preset...</option>
<option value="fps_combat" data-i18n="game_integration.preset.fps_combat">FPS Combat</option>
<option value="moba_health" data-i18n="game_integration.preset.moba_health">MOBA Health</option>
</select>
<button class="btn btn-secondary btn-sm" onclick="addGameMapping()" data-i18n="game_integration.mapping.add">+ Add Mapping</button>
</div>
<div id="gi-mappings-list" class="gi-mappings-list"></div>
</div>
<!-- Live Event Monitor -->
<div class="form-group">
<div class="label-row">
<label data-i18n="game_integration.events.title">Live Events</label>
</div>
<div id="gi-event-feed" class="gi-event-feed"></div>
</div>
<!-- Connection Test -->
<div class="form-group">
<button class="btn btn-secondary" onclick="testGameConnection()" data-i18n="game_integration.test.button">Test Connection</button>
<div id="gi-test-panel" style="display:none" class="gi-test-panel"></div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-icon btn-secondary" onclick="closeGameIntegrationModal()" title="Cancel" data-i18n-title="settings.button.cancel" data-i18n-aria-label="aria.cancel">&#x2715;</button>
<button class="btn btn-icon btn-primary" onclick="saveGameIntegration()" title="Save" data-i18n-title="settings.button.save" data-i18n-aria-label="aria.save">&#x2713;</button>
</div>
</div>
</div>
<!-- Setup Instructions Overlay (full-screen, same pattern as release notes) -->
<div id="gi-setup-overlay" class="log-overlay" style="display:none;">
<button class="log-overlay-close" onclick="closeSetupInstructions()" title="Close" data-i18n-aria-label="aria.close">&#x2715;</button>
<div class="log-overlay-toolbar">
<h3 id="gi-setup-overlay-title" data-i18n="game_integration.setup_instructions">Setup Instructions</h3>
</div>
<div id="gi-setup-overlay-content" class="release-notes-content"></div>
</div>
@@ -535,6 +535,78 @@
</div>
</div>
<!-- Game Event value source fields -->
<div id="value-source-game-event-section" style="display:none">
<div class="form-group">
<div class="label-row">
<label for="value-source-game-integration" data-i18n="value_source.game_event.integration">Game Integration:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="value_source.game_event.integration.hint">Select the game integration that provides events for this value source.</small>
<select id="value-source-game-integration">
<option value=""></option>
</select>
</div>
<div class="form-group">
<div class="label-row">
<label for="value-source-game-event-type" data-i18n="value_source.game_event.event_type">Event Type:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="value_source.game_event.event_type.hint">The continuous game event to track (health, mana, ammo, etc.).</small>
<select id="value-source-game-event-type">
<option value="health">health</option>
<option value="armor">armor</option>
<option value="mana">mana</option>
<option value="ammo">ammo</option>
<option value="stamina">stamina</option>
<option value="shield">shield</option>
<option value="score">score</option>
<option value="gold">gold</option>
<option value="xp">xp</option>
<option value="level">level</option>
</select>
</div>
<div class="form-group">
<div class="label-row">
<label for="value-source-ge-min"><span data-i18n="value_source.game_event.min_game_value">Min Game Value:</span> <span id="value-source-ge-min-display">0</span></label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="value_source.game_event.min_game_value.hint">Raw game value that maps to output 0.0.</small>
<input type="number" id="value-source-ge-min" step="any" value="0">
</div>
<div class="form-group">
<div class="label-row">
<label for="value-source-ge-max"><span data-i18n="value_source.game_event.max_game_value">Max Game Value:</span> <span id="value-source-ge-max-display">100</span></label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="value_source.game_event.max_game_value.hint">Raw game value that maps to output 1.0.</small>
<input type="number" id="value-source-ge-max" step="any" value="100">
</div>
<div class="form-group">
<label for="value-source-ge-smoothing"><span data-i18n="value_source.game_event.smoothing">Smoothing:</span> <span id="value-source-ge-smoothing-display">0</span></label>
<input type="range" id="value-source-ge-smoothing" min="0" max="0.99" step="0.01" value="0"
oninput="document.getElementById('value-source-ge-smoothing-display').textContent = this.value">
</div>
<div class="form-group">
<div class="label-row">
<label for="value-source-ge-default"><span data-i18n="value_source.game_event.default_value">Default Value:</span> <span id="value-source-ge-default-display">0.5</span></label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="value_source.game_event.default_value.hint">Output value when no events received within timeout.</small>
<input type="range" id="value-source-ge-default" min="0" max="1" step="0.01" value="0.5"
oninput="document.getElementById('value-source-ge-default-display').textContent = this.value">
</div>
<div class="form-group">
<div class="label-row">
<label for="value-source-ge-timeout"><span data-i18n="value_source.game_event.timeout">Timeout (s):</span> <span id="value-source-ge-timeout-display">5.0</span></label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="value_source.game_event.timeout.hint">Seconds of silence before reverting to the default value.</small>
<input type="range" id="value-source-ge-timeout" min="1" max="60" step="0.5" value="5"
oninput="document.getElementById('value-source-ge-timeout-display').textContent = parseFloat(this.value).toFixed(1)">
</div>
</div>
<!-- Shared adaptive output range (shown for adaptive and daylight types) -->
<div id="value-source-adaptive-range-section" style="display:none">
<div class="form-group">
@@ -0,0 +1,495 @@
"""Tests for game integration API routes.
Uses FastAPI TestClient with dependency overrides to test route handlers
in isolation from the real application.
"""
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from wled_controller.api.routes.game_integration import router
from wled_controller.core.game_integration.adapter_registry import AdapterRegistry
from wled_controller.core.game_integration.base_adapter import GameAdapter
from wled_controller.core.game_integration.event_bus import GameEventBus
from wled_controller.core.game_integration.events import GameEvent
from wled_controller.storage.game_integration_store import GameIntegrationStore
from wled_controller.api import dependencies as deps
# ---------------------------------------------------------------------------
# Test adapter for ingestion tests
# ---------------------------------------------------------------------------
class _TestAdapter(GameAdapter):
ADAPTER_TYPE = "test_adapter"
DISPLAY_NAME = "Test Adapter"
GAME_NAME = "Test Game"
SUPPORTED_EVENTS = ["health", "kill"]
@classmethod
def parse_payload(cls, payload, adapter_config, prev_state):
events = []
if "health" in payload:
events.append(
GameEvent(
adapter_id=adapter_config.get("integration_id", "test"),
event_type="health",
value=payload["health"] / 100.0,
)
)
return events, prev_state
@classmethod
def validate_auth(cls, headers, payload, adapter_config):
token = adapter_config.get("auth_token")
if not token:
return True
return headers.get("x-auth-token") == token
@classmethod
def get_config_schema(cls):
return {
"type": "object",
"properties": {
"auth_token": {"type": "string", "description": "Auth token"},
},
}
@classmethod
def get_setup_instructions(cls):
return "Configure the test adapter with an auth token."
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
def _make_app():
app = FastAPI()
app.include_router(router)
return app
@pytest.fixture
def _route_db(tmp_path):
from wled_controller.storage.database import Database
db = Database(tmp_path / "test.db")
yield db
db.close()
@pytest.fixture
def game_store(_route_db):
return GameIntegrationStore(_route_db)
@pytest.fixture
def event_bus():
return GameEventBus()
@pytest.fixture(autouse=True)
def _register_test_adapter():
"""Register and clean up the test adapter for each test."""
AdapterRegistry.register(_TestAdapter)
yield
AdapterRegistry.clear_registry()
@pytest.fixture
def client(game_store, event_bus):
app = _make_app()
# Override auth to always pass
from wled_controller.api.auth import verify_api_key
app.dependency_overrides[verify_api_key] = lambda: "test-user"
app.dependency_overrides[deps.get_game_integration_store] = lambda: game_store
app.dependency_overrides[deps.get_game_event_bus] = lambda: event_bus
return TestClient(app, raise_server_exceptions=False)
# ---------------------------------------------------------------------------
# Helper
# ---------------------------------------------------------------------------
def _create_integration(client, name="Test Integration", adapter_type="test_adapter", **kwargs):
"""Helper to create an integration via API."""
body = {
"name": name,
"adapter_type": adapter_type,
**kwargs,
}
resp = client.post("/api/v1/game-integrations", json=body)
assert resp.status_code == 201, resp.text
return resp.json()
# ---------------------------------------------------------------------------
# CRUD tests
# ---------------------------------------------------------------------------
class TestCreateIntegration:
def test_create_basic(self, client):
data = _create_integration(client)
assert data["id"].startswith("gi_")
assert data["name"] == "Test Integration"
assert data["adapter_type"] == "test_adapter"
assert data["enabled"] is True
def test_create_with_mappings(self, client):
data = _create_integration(
client,
event_mappings=[
{"event_type": "health", "effect": "gradient", "color": [255, 0, 0]},
{"event_type": "kill", "effect": "flash", "color": [0, 255, 0]},
],
)
assert len(data["event_mappings"]) == 2
assert data["event_mappings"][0]["event_type"] == "health"
def test_create_with_config(self, client):
data = _create_integration(
client,
adapter_config={"auth_token": "secret123"},
description="My game",
tags=["fps"],
)
assert data["adapter_config"] == {"auth_token": "secret123"}
assert data["description"] == "My game"
assert data["tags"] == ["fps"]
def test_create_duplicate_name(self, client):
_create_integration(client, name="Unique")
resp = client.post(
"/api/v1/game-integrations",
json={"name": "Unique", "adapter_type": "test_adapter"},
)
assert resp.status_code == 400
assert "already exists" in resp.json()["detail"]
def test_create_empty_name(self, client):
resp = client.post(
"/api/v1/game-integrations",
json={"name": "", "adapter_type": "test_adapter"},
)
assert resp.status_code == 422 # Pydantic validation
class TestListIntegrations:
def test_list_empty(self, client):
resp = client.get("/api/v1/game-integrations")
assert resp.status_code == 200
data = resp.json()
assert data["count"] == 0
assert data["integrations"] == []
def test_list_multiple(self, client):
_create_integration(client, name="Int 1")
_create_integration(client, name="Int 2")
resp = client.get("/api/v1/game-integrations")
data = resp.json()
assert data["count"] == 2
names = {i["name"] for i in data["integrations"]}
assert names == {"Int 1", "Int 2"}
class TestGetIntegration:
def test_get_existing(self, client):
created = _create_integration(client)
resp = client.get(f"/api/v1/game-integrations/{created['id']}")
assert resp.status_code == 200
assert resp.json()["name"] == "Test Integration"
def test_get_nonexistent(self, client):
resp = client.get("/api/v1/game-integrations/gi_nonexist")
assert resp.status_code == 404
class TestUpdateIntegration:
def test_update_name(self, client):
created = _create_integration(client)
resp = client.put(
f"/api/v1/game-integrations/{created['id']}",
json={"name": "Updated Name"},
)
assert resp.status_code == 200
assert resp.json()["name"] == "Updated Name"
def test_update_enabled(self, client):
created = _create_integration(client)
resp = client.put(
f"/api/v1/game-integrations/{created['id']}",
json={"enabled": False},
)
assert resp.status_code == 200
assert resp.json()["enabled"] is False
def test_update_nonexistent(self, client):
resp = client.put(
"/api/v1/game-integrations/gi_nonexist",
json={"name": "x"},
)
assert resp.status_code == 404
def test_update_to_duplicate_name(self, client):
_create_integration(client, name="Name A")
b = _create_integration(client, name="Name B")
resp = client.put(
f"/api/v1/game-integrations/{b['id']}",
json={"name": "Name A"},
)
assert resp.status_code == 400
class TestDeleteIntegration:
def test_delete_existing(self, client):
created = _create_integration(client)
resp = client.delete(f"/api/v1/game-integrations/{created['id']}")
assert resp.status_code == 204
# Verify it's gone
resp = client.get(f"/api/v1/game-integrations/{created['id']}")
assert resp.status_code == 404
def test_delete_nonexistent(self, client):
resp = client.delete("/api/v1/game-integrations/gi_nonexist")
assert resp.status_code == 404
# ---------------------------------------------------------------------------
# Event ingestion tests
# ---------------------------------------------------------------------------
class TestEventIngestion:
def test_ingest_basic(self, client, event_bus):
created = _create_integration(client)
integration_id = created["id"]
resp = client.post(
f"/api/v1/game-integrations/{integration_id}/event",
json={"data": {"health": 75}},
)
assert resp.status_code == 204
# Verify event was published to bus
recent = event_bus.get_recent_events()
assert len(recent) == 1
assert recent[0].event_type == "health"
assert recent[0].value == 0.75
def test_ingest_disabled_integration(self, client):
created = _create_integration(client)
integration_id = created["id"]
# Disable the integration
client.put(
f"/api/v1/game-integrations/{integration_id}",
json={"enabled": False},
)
resp = client.post(
f"/api/v1/game-integrations/{integration_id}/event",
json={"data": {"health": 50}},
)
assert resp.status_code == 409
def test_ingest_nonexistent_integration(self, client):
resp = client.post(
"/api/v1/game-integrations/gi_nonexist/event",
json={"data": {"health": 50}},
)
assert resp.status_code == 404
def test_ingest_auth_failure(self, client):
created = _create_integration(
client,
adapter_config={"auth_token": "correct_token"},
)
integration_id = created["id"]
resp = client.post(
f"/api/v1/game-integrations/{integration_id}/event",
json={"data": {"health": 50}},
headers={"x-auth-token": "wrong_token"},
)
assert resp.status_code == 403
def test_ingest_auth_success(self, client, event_bus):
created = _create_integration(
client,
adapter_config={"auth_token": "correct_token"},
)
integration_id = created["id"]
resp = client.post(
f"/api/v1/game-integrations/{integration_id}/event",
json={"data": {"health": 50}},
headers={"x-auth-token": "correct_token"},
)
assert resp.status_code == 204
recent = event_bus.get_recent_events()
assert len(recent) == 1
# ---------------------------------------------------------------------------
# Status / diagnostics tests
# ---------------------------------------------------------------------------
class TestStatus:
def test_status_no_events(self, client):
created = _create_integration(client)
resp = client.get(f"/api/v1/game-integrations/{created['id']}/status")
assert resp.status_code == 200
data = resp.json()
assert data["integration_id"] == created["id"]
assert data["enabled"] is True
assert data["connected"] is False
assert data["event_count"] == 0
def test_status_after_events(self, client):
created = _create_integration(client)
integration_id = created["id"]
# Send an event
client.post(
f"/api/v1/game-integrations/{integration_id}/event",
json={"data": {"health": 80}},
)
resp = client.get(f"/api/v1/game-integrations/{integration_id}/status")
data = resp.json()
assert data["event_count"] == 1
assert data["connected"] is True
assert "health" in data["event_counts_by_type"]
def test_status_nonexistent(self, client):
resp = client.get("/api/v1/game-integrations/gi_nonexist/status")
assert resp.status_code == 404
class TestRecentEvents:
def test_recent_events_empty(self, client):
created = _create_integration(client)
resp = client.get(f"/api/v1/game-integrations/{created['id']}/events")
assert resp.status_code == 200
data = resp.json()
assert data["count"] == 0
assert data["events"] == []
def test_recent_events_nonexistent(self, client):
resp = client.get("/api/v1/game-integrations/gi_nonexist/events")
assert resp.status_code == 404
# ---------------------------------------------------------------------------
# Adapter metadata tests
# ---------------------------------------------------------------------------
class TestAdapterMetadata:
def test_list_adapters(self, client):
resp = client.get("/api/v1/game-adapters")
assert resp.status_code == 200
data = resp.json()
assert data["count"] >= 1
adapter = data["adapters"][0]
assert adapter["adapter_type"] == "test_adapter"
assert adapter["display_name"] == "Test Adapter"
assert adapter["game_name"] == "Test Game"
assert "health" in adapter["supported_events"]
assert "config_schema" in adapter
assert "setup_instructions" in adapter
# ---------------------------------------------------------------------------
# Preset tests
# ---------------------------------------------------------------------------
class TestPresets:
def test_list_presets(self, client):
resp = client.get("/api/v1/game-integrations/presets")
assert resp.status_code == 200
data = resp.json()
assert data["count"] >= 4
keys = {p["key"] for p in data["presets"]}
assert "fps_combat" in keys
assert "moba_health" in keys
assert "racing" in keys
assert "generic_alert" in keys
def test_preset_has_mappings(self, client):
resp = client.get("/api/v1/game-integrations/presets")
data = resp.json()
for preset in data["presets"]:
assert len(preset["event_mappings"]) > 0
for m in preset["event_mappings"]:
assert "event_type" in m
assert "effect" in m
assert "color" in m
def test_apply_preset_replace(self, client):
created = _create_integration(
client,
event_mappings=[{"event_type": "kill", "effect": "flash", "color": [255, 0, 0]}],
)
integration_id = created["id"]
assert len(created["event_mappings"]) == 1
resp = client.post(
f"/api/v1/game-integrations/{integration_id}/apply-preset",
json={"preset_key": "fps_combat", "replace": True},
)
assert resp.status_code == 200
data = resp.json()
# Should have replaced the single mapping with preset mappings
assert len(data["event_mappings"]) >= 3
def test_apply_preset_append(self, client):
created = _create_integration(
client,
event_mappings=[{"event_type": "kill", "effect": "flash", "color": [255, 0, 0]}],
)
integration_id = created["id"]
resp = client.post(
f"/api/v1/game-integrations/{integration_id}/apply-preset",
json={"preset_key": "generic_alert", "replace": False},
)
assert resp.status_code == 200
data = resp.json()
# Should have original + preset mappings
assert len(data["event_mappings"]) >= 5
def test_apply_preset_unknown_key(self, client):
created = _create_integration(client)
resp = client.post(
f"/api/v1/game-integrations/{created['id']}/apply-preset",
json={"preset_key": "nonexistent"},
)
assert resp.status_code == 404
assert "not found" in resp.json()["detail"]
def test_apply_preset_unknown_integration(self, client):
resp = client.post(
"/api/v1/game-integrations/gi_nonexist/apply-preset",
json={"preset_key": "fps_combat"},
)
assert resp.status_code == 404
+3 -2
View File
@@ -187,8 +187,8 @@ def make_automation():
id=f"auto_test_{_counter:04d}",
name=name or f"Automation {_counter}",
enabled=True,
condition_logic="or",
conditions=[],
rule_logic="or",
rules=[],
scene_preset_id=None,
deactivation_mode="none",
deactivation_scene_preset_id=None,
@@ -213,6 +213,7 @@ def authenticated_client(test_config, monkeypatch):
Patches global config so the app uses temp storage paths.
"""
import wled_controller.config as config_mod
monkeypatch.setattr(config_mod, "config", test_config)
from fastapi.testclient import TestClient
+135
View File
@@ -0,0 +1,135 @@
"""Tests for AdapterRegistry — register, get, duplicates, clear."""
from typing import Any
import pytest
from wled_controller.core.game_integration.adapter_registry import AdapterRegistry
from wled_controller.core.game_integration.base_adapter import GameAdapter
from wled_controller.core.game_integration.events import GameEvent
class FakeAdapter(GameAdapter):
ADAPTER_TYPE = "fake_game"
DISPLAY_NAME = "Fake Game"
GAME_NAME = "FakeGame"
SUPPORTED_EVENTS = ["health", "kill"]
@classmethod
def parse_payload(
cls,
payload: dict[str, Any],
adapter_config: dict[str, Any],
prev_state: dict[str, Any],
) -> tuple[list[GameEvent], dict[str, Any]]:
return [], prev_state
@classmethod
def validate_auth(
cls,
headers: dict[str, str],
payload: dict[str, Any],
adapter_config: dict[str, Any],
) -> bool:
return True
class AnotherAdapter(GameAdapter):
ADAPTER_TYPE = "another_game"
DISPLAY_NAME = "Another Game"
GAME_NAME = "AnotherGame"
SUPPORTED_EVENTS = ["mana"]
@classmethod
def parse_payload(
cls,
payload: dict[str, Any],
adapter_config: dict[str, Any],
prev_state: dict[str, Any],
) -> tuple[list[GameEvent], dict[str, Any]]:
return [], prev_state
@classmethod
def validate_auth(
cls,
headers: dict[str, str],
payload: dict[str, Any],
adapter_config: dict[str, Any],
) -> bool:
return True
@pytest.fixture(autouse=True)
def _clean_registry():
"""Ensure a clean registry for each test."""
AdapterRegistry.clear_registry()
yield
AdapterRegistry.clear_registry()
class TestRegister:
def test_register_and_get(self) -> None:
AdapterRegistry.register(FakeAdapter)
assert AdapterRegistry.get_adapter("fake_game") is FakeAdapter
def test_register_multiple(self) -> None:
AdapterRegistry.register(FakeAdapter)
AdapterRegistry.register(AnotherAdapter)
assert AdapterRegistry.get_adapter("fake_game") is FakeAdapter
assert AdapterRegistry.get_adapter("another_game") is AnotherAdapter
def test_register_duplicate_overwrites(self) -> None:
AdapterRegistry.register(FakeAdapter)
# Re-registering the same type should overwrite without error
AdapterRegistry.register(FakeAdapter)
assert AdapterRegistry.get_adapter("fake_game") is FakeAdapter
def test_register_non_subclass_raises(self) -> None:
with pytest.raises(ValueError, match="must be a subclass"):
AdapterRegistry.register(str) # type: ignore[arg-type]
def test_register_base_type_raises(self) -> None:
with pytest.raises(ValueError, match="reserved type"):
AdapterRegistry.register(GameAdapter) # type: ignore[arg-type]
class TestGetAdapter:
def test_get_unknown_raises(self) -> None:
with pytest.raises(ValueError, match="Unknown adapter type"):
AdapterRegistry.get_adapter("nonexistent")
def test_get_unknown_shows_available(self) -> None:
AdapterRegistry.register(FakeAdapter)
with pytest.raises(ValueError, match="fake_game"):
AdapterRegistry.get_adapter("nonexistent")
class TestGetAll:
def test_get_all_returns_copy(self) -> None:
AdapterRegistry.register(FakeAdapter)
all_adapters = AdapterRegistry.get_all_adapters()
assert "fake_game" in all_adapters
# Mutating the copy should not affect the registry
all_adapters.pop("fake_game")
assert AdapterRegistry.get_adapter("fake_game") is FakeAdapter
def test_get_available_adapters_metadata(self) -> None:
AdapterRegistry.register(FakeAdapter)
available = AdapterRegistry.get_available_adapters()
assert len(available) == 1
meta = available[0]
assert meta["adapter_type"] == "fake_game"
assert meta["display_name"] == "Fake Game"
assert meta["game_name"] == "FakeGame"
assert meta["supported_events"] == ["health", "kill"]
class TestClear:
def test_clear_removes_all(self) -> None:
AdapterRegistry.register(FakeAdapter)
AdapterRegistry.register(AnotherAdapter)
AdapterRegistry.clear_registry()
assert AdapterRegistry.get_all_adapters() == {}
+58 -60
View File
@@ -1,4 +1,4 @@
"""Tests for AutomationEngine — condition evaluation in isolation."""
"""Tests for AutomationEngine — rule evaluation in isolation."""
from datetime import datetime, timezone
from unittest.mock import MagicMock, patch
@@ -7,14 +7,13 @@ import pytest
from wled_controller.core.automations.automation_engine import AutomationEngine
from wled_controller.storage.automation import (
AlwaysCondition,
ApplicationCondition,
ApplicationRule,
Automation,
DisplayStateCondition,
StartupCondition,
SystemIdleCondition,
TimeOfDayCondition,
WebhookCondition,
DisplayStateRule,
StartupRule,
SystemIdleRule,
TimeOfDayRule,
WebhookRule,
)
from wled_controller.storage.automation_store import AutomationStore
@@ -44,9 +43,7 @@ def engine(mock_store, mock_manager) -> AutomationEngine:
causes access violations in the test environment, so we replace it
with a simple MagicMock.
"""
with patch(
"wled_controller.core.automations.automation_engine.PlatformDetector"
):
with patch("wled_controller.core.automations.automation_engine.PlatformDetector"):
eng = AutomationEngine(
automation_store=mock_store,
processor_manager=mock_manager,
@@ -56,21 +53,21 @@ def engine(mock_store, mock_manager) -> AutomationEngine:
# ---------------------------------------------------------------------------
# Condition evaluation (unit-level)
# Rule evaluation (unit-level)
# ---------------------------------------------------------------------------
class TestConditionEvaluation:
"""Test _evaluate_condition for each condition type individually."""
class TestRuleEvaluation:
"""Test _evaluate_rule for each rule type individually."""
def _make_automation(self, conditions):
def _make_automation(self, rules):
now = datetime.now(timezone.utc)
return Automation(
id="test_auto",
name="Test",
enabled=True,
condition_logic="or",
conditions=conditions,
rule_logic="or",
rules=rules,
scene_preset_id=None,
deactivation_mode="none",
deactivation_scene_preset_id=None,
@@ -78,8 +75,8 @@ class TestConditionEvaluation:
updated_at=now,
)
def _eval(self, engine, condition, **kwargs):
"""Invoke the private _evaluate_condition method."""
def _eval(self, engine, rule, **kwargs):
"""Invoke the private _evaluate_rule method."""
defaults = dict(
running_procs=set(),
topmost_proc=None,
@@ -89,8 +86,8 @@ class TestConditionEvaluation:
display_state=None,
)
defaults.update(kwargs)
return engine._evaluate_condition(
condition,
return engine._evaluate_rule(
rule,
defaults["running_procs"],
defaults["topmost_proc"],
defaults["topmost_fullscreen"],
@@ -99,102 +96,103 @@ class TestConditionEvaluation:
defaults["display_state"],
)
def test_always_true(self, engine):
assert self._eval(engine, AlwaysCondition()) is True
def test_startup_true(self, engine):
assert self._eval(engine, StartupCondition()) is True
assert self._eval(engine, StartupRule()) is True
def test_application_running_match(self, engine):
cond = ApplicationCondition(apps=["chrome.exe"], match_type="running")
rule = ApplicationRule(apps=["chrome.exe"], match_type="running")
result = self._eval(
engine, cond,
engine,
rule,
running_procs={"chrome.exe", "explorer.exe"},
)
assert result is True
def test_application_running_no_match(self, engine):
cond = ApplicationCondition(apps=["chrome.exe"], match_type="running")
rule = ApplicationRule(apps=["chrome.exe"], match_type="running")
result = self._eval(
engine, cond,
engine,
rule,
running_procs={"explorer.exe"},
)
assert result is False
def test_application_topmost_match(self, engine):
cond = ApplicationCondition(apps=["game.exe"], match_type="topmost")
rule = ApplicationRule(apps=["game.exe"], match_type="topmost")
result = self._eval(
engine, cond,
engine,
rule,
topmost_proc="game.exe",
)
assert result is True
def test_application_topmost_no_match(self, engine):
cond = ApplicationCondition(apps=["game.exe"], match_type="topmost")
rule = ApplicationRule(apps=["game.exe"], match_type="topmost")
result = self._eval(
engine, cond,
engine,
rule,
topmost_proc="chrome.exe",
)
assert result is False
def test_time_of_day_within_range(self, engine):
cond = TimeOfDayCondition(start_time="00:00", end_time="23:59")
result = self._eval(engine, cond)
rule = TimeOfDayRule(start_time="00:00", end_time="23:59")
result = self._eval(engine, rule)
assert result is True
def test_system_idle_when_idle(self, engine):
cond = SystemIdleCondition(idle_minutes=5, when_idle=True)
result = self._eval(engine, cond, idle_seconds=600.0) # 10 minutes idle
rule = SystemIdleRule(idle_minutes=5, when_idle=True)
result = self._eval(engine, rule, idle_seconds=600.0) # 10 minutes idle
assert result is True
def test_system_idle_not_idle(self, engine):
cond = SystemIdleCondition(idle_minutes=5, when_idle=True)
result = self._eval(engine, cond, idle_seconds=60.0) # 1 minute idle
rule = SystemIdleRule(idle_minutes=5, when_idle=True)
result = self._eval(engine, rule, idle_seconds=60.0) # 1 minute idle
assert result is False
def test_system_idle_when_not_idle(self, engine):
"""when_idle=False means active when user is NOT idle."""
cond = SystemIdleCondition(idle_minutes=5, when_idle=False)
result = self._eval(engine, cond, idle_seconds=60.0) # 1 min idle (not yet 5)
rule = SystemIdleRule(idle_minutes=5, when_idle=False)
result = self._eval(engine, rule, idle_seconds=60.0) # 1 min idle (not yet 5)
assert result is True
def test_display_state_match(self, engine):
cond = DisplayStateCondition(state="on")
result = self._eval(engine, cond, display_state="on")
rule = DisplayStateRule(state="on")
result = self._eval(engine, rule, display_state="on")
assert result is True
def test_display_state_no_match(self, engine):
cond = DisplayStateCondition(state="off")
result = self._eval(engine, cond, display_state="on")
rule = DisplayStateRule(state="off")
result = self._eval(engine, rule, display_state="on")
assert result is False
def test_webhook_active(self, engine):
cond = WebhookCondition(token="tok123")
rule = WebhookRule(token="tok123")
engine._webhook_states["tok123"] = True
result = self._eval(engine, cond)
result = self._eval(engine, rule)
assert result is True
def test_webhook_inactive(self, engine):
cond = WebhookCondition(token="tok123")
rule = WebhookRule(token="tok123")
# Not in _webhook_states → False
result = self._eval(engine, cond)
result = self._eval(engine, rule)
assert result is False
# ---------------------------------------------------------------------------
# Condition logic (AND / OR)
# Rule logic (AND / OR)
# ---------------------------------------------------------------------------
class TestConditionLogic:
def _make_automation(self, conditions, logic="or"):
class TestRuleLogic:
def _make_automation(self, rules, logic="or"):
now = datetime.now(timezone.utc)
return Automation(
id="logic_auto",
name="Logic",
enabled=True,
condition_logic=logic,
conditions=conditions,
rule_logic=logic,
rules=rules,
scene_preset_id=None,
deactivation_mode="none",
deactivation_scene_preset_id=None,
@@ -205,12 +203,12 @@ class TestConditionLogic:
def test_or_any_true(self, engine):
auto = self._make_automation(
[
ApplicationCondition(apps=["missing.exe"], match_type="running"),
AlwaysCondition(),
ApplicationRule(apps=["missing.exe"], match_type="running"),
StartupRule(),
],
logic="or",
)
result = engine._evaluate_conditions(
result = engine._evaluate_rules(
auto,
running_procs=set(),
topmost_proc=None,
@@ -224,12 +222,12 @@ class TestConditionLogic:
def test_and_all_must_be_true(self, engine):
auto = self._make_automation(
[
AlwaysCondition(),
ApplicationCondition(apps=["missing.exe"], match_type="running"),
StartupRule(),
ApplicationRule(apps=["missing.exe"], match_type="running"),
],
logic="and",
)
result = engine._evaluate_conditions(
result = engine._evaluate_rules(
auto,
running_procs=set(),
topmost_proc=None,
@@ -0,0 +1,282 @@
"""Tests for community adapter YAML loader."""
import textwrap
from pathlib import Path
import pytest
from wled_controller.core.game_integration.community_loader import (
clear_community_adapters,
get_community_adapter,
get_community_adapter_info,
get_community_adapters,
load_community_adapters,
register_community_adapters,
)
@pytest.fixture(autouse=True)
def _clean_registry() -> None:
"""Clear community adapters before and after each test."""
clear_community_adapters()
yield # type: ignore[misc]
clear_community_adapters()
def _write_yaml(directory: Path, name: str, content: str) -> Path:
"""Write a YAML file to a directory."""
path = directory / name
path.write_text(textwrap.dedent(content))
return path
class TestLoadCommunityAdapters:
def test_load_from_directory(self, tmp_path: Path) -> None:
_write_yaml(
tmp_path,
"test_game.yaml",
"""\
name: test_game
game: Test Game
protocol: webhook
mappings:
- source_path: player.health
event: health
min: 0
max: 100
""",
)
adapters = load_community_adapters(tmp_path)
assert "community_test_game" in adapters
assert adapters["community_test_game"].game == "Test Game"
def test_load_multiple_files(self, tmp_path: Path) -> None:
_write_yaml(
tmp_path,
"game_a.yaml",
"""\
name: game_a
game: Game A
protocol: webhook
mappings:
- source_path: hp
event: health
""",
)
_write_yaml(
tmp_path,
"game_b.yaml",
"""\
name: game_b
game: Game B
protocol: webhook
mappings:
- source_path: mp
event: mana
""",
)
adapters = load_community_adapters(tmp_path)
assert len(adapters) == 2
assert "community_game_a" in adapters
assert "community_game_b" in adapters
def test_load_nonexistent_directory(self, tmp_path: Path) -> None:
nonexistent = tmp_path / "does_not_exist"
adapters = load_community_adapters(nonexistent)
assert adapters == {}
def test_skip_invalid_yaml(self, tmp_path: Path) -> None:
_write_yaml(
tmp_path,
"valid.yaml",
"""\
name: valid
game: Valid Game
protocol: webhook
mappings:
- source_path: hp
event: health
""",
)
_write_yaml(
tmp_path,
"invalid.yaml",
"""\
name: invalid
protocol: webhook
mappings: []
""",
)
adapters = load_community_adapters(tmp_path)
assert len(adapters) == 1
assert "community_valid" in adapters
def test_load_yml_extension(self, tmp_path: Path) -> None:
_write_yaml(
tmp_path,
"game.yml",
"""\
name: yml_game
game: YML Game
protocol: webhook
mappings:
- source_path: hp
event: health
""",
)
adapters = load_community_adapters(tmp_path)
assert "community_game" in adapters
def test_empty_directory(self, tmp_path: Path) -> None:
adapters = load_community_adapters(tmp_path)
assert adapters == {}
class TestRegisterCommunityAdapters:
def test_register_and_retrieve(self, tmp_path: Path) -> None:
_write_yaml(
tmp_path,
"test.yaml",
"""\
name: test
game: Test
protocol: webhook
mappings:
- source_path: hp
event: health
""",
)
count = register_community_adapters(tmp_path)
assert count == 1
adapters = get_community_adapters()
assert "community_test" in adapters
def test_get_single_adapter(self, tmp_path: Path) -> None:
_write_yaml(
tmp_path,
"test.yaml",
"""\
name: test
game: Test
protocol: webhook
mappings:
- source_path: hp
event: health
""",
)
register_community_adapters(tmp_path)
adapter = get_community_adapter("community_test")
assert adapter is not None
assert adapter.name == "test"
def test_get_nonexistent_adapter(self) -> None:
adapter = get_community_adapter("community_nonexistent")
assert adapter is None
def test_get_adapter_info(self, tmp_path: Path) -> None:
_write_yaml(
tmp_path,
"my_game.yaml",
"""\
name: My Game Adapter
game: My Game
protocol: webhook
mappings:
- source_path: hp
event: health
- source_path: kills
event: kill
""",
)
register_community_adapters(tmp_path)
info = get_community_adapter_info()
assert len(info) == 1
assert info[0]["adapter_type"] == "community_my_game"
assert info[0]["display_name"] == "My Game Adapter"
assert info[0]["game_name"] == "My Game"
assert info[0]["source"] == "community"
assert "health" in info[0]["supported_events"]
assert "kill" in info[0]["supported_events"]
class TestBuiltInYamlFiles:
"""Test that the shipped YAML adapter files load correctly."""
def test_load_bundled_adapters(self) -> None:
"""Load the built-in game_adapters directory."""
bundled_dir = (
Path(__file__).parent.parent.parent
/ "src"
/ "wled_controller"
/ "data"
/ "game_adapters"
)
if not bundled_dir.exists():
pytest.skip("Bundled adapter directory not found")
adapters = load_community_adapters(bundled_dir)
# We ship minecraft, valorant, rocket_league
assert len(adapters) >= 3
assert "community_minecraft" in adapters
assert "community_valorant" in adapters
assert "community_rocket_league" in adapters
def test_minecraft_adapter_parses(self) -> None:
"""Test that the Minecraft community adapter can parse a payload."""
bundled_dir = (
Path(__file__).parent.parent.parent
/ "src"
/ "wled_controller"
/ "data"
/ "game_adapters"
)
adapters = load_community_adapters(bundled_dir)
mc = adapters.get("community_minecraft")
if mc is None:
pytest.skip("Minecraft adapter not found")
payload = {
"player": {
"health": 15,
"armor": 10,
"food_level": 18,
"experience_level": 30,
},
"stats": {"kills": 5},
}
events, _ = mc.parse_payload(payload, {"adapter_id": "mc_test"}, {})
types = {e.event_type for e in events}
assert "health" in types
assert "armor" in types
def test_rocket_league_adapter_parses(self) -> None:
"""Test that the Rocket League community adapter can parse a payload."""
bundled_dir = (
Path(__file__).parent.parent.parent
/ "src"
/ "wled_controller"
/ "data"
/ "game_adapters"
)
adapters = load_community_adapters(bundled_dir)
rl = adapters.get("community_rocket_league")
if rl is None:
pytest.skip("Rocket League adapter not found")
payload = {
"player": {"boost": 50, "speed": 1150},
"match": {"goals_scored": 2, "goals_conceded": 1, "time_remaining": 150},
"team": {"score_blue": 2, "score_orange": 1},
}
events, _ = rl.parse_payload(payload, {"adapter_id": "rl_test"}, {})
types = {e.event_type for e in events}
assert "energy" in types
assert "speed" in types
+279
View File
@@ -0,0 +1,279 @@
"""Tests for CS2 Game State Integration adapter."""
import pytest
from wled_controller.core.game_integration.adapters.cs2_adapter import CS2Adapter
# ── Realistic CS2 GSI payload samples ────────────────────────────────────
def _make_cs2_payload(
*,
health: int = 100,
armor: int = 100,
money: int = 800,
kills: int = 0,
deaths: int = 0,
round_phase: str | None = None,
bomb: str | None = None,
flashed: int = 0,
team: str = "CT",
ammo_clip: int | None = None,
ammo_clip_max: int | None = None,
auth_token: str | None = None,
) -> dict:
"""Build a realistic CS2 GSI payload."""
payload: dict = {
"player": {
"steamid": "76561198012345678",
"name": "TestPlayer",
"team": team,
"state": {
"health": health,
"armor": armor,
"helmet": True,
"flashed": flashed,
"smoked": 0,
"burning": 0,
"money": money,
"round_kills": 0,
"round_killhs": 0,
"equip_value": 4400,
},
"match_stats": {
"kills": kills,
"assists": 0,
"deaths": deaths,
"mvps": 0,
"score": kills * 2,
},
"weapons": {},
},
}
if ammo_clip is not None and ammo_clip_max is not None:
payload["player"]["weapons"]["weapon_0"] = {
"name": "weapon_ak47",
"paintkit": "default",
"type": "Rifle",
"ammo_clip": ammo_clip,
"ammo_clip_max": ammo_clip_max,
"ammo_reserve": 90,
"state": "active",
}
if round_phase is not None:
payload["round"] = {"phase": round_phase}
if bomb is not None:
payload.setdefault("round", {})["bomb"] = bomb
if auth_token is not None:
payload["auth"] = {"token": auth_token}
return payload
# ── Health/Armor/Money tests ─────────────────────────────────────────────
class TestCS2ContinuousEvents:
def test_health_normalized(self) -> None:
payload = _make_cs2_payload(health=75)
events, _ = CS2Adapter.parse_payload(payload, {"adapter_id": "cs2_test"}, {})
health_events = [e for e in events if e.event_type == "health"]
assert len(health_events) == 1
assert health_events[0].value == pytest.approx(0.75)
def test_health_zero(self) -> None:
payload = _make_cs2_payload(health=0)
events, _ = CS2Adapter.parse_payload(payload, {"adapter_id": "cs2_test"}, {})
health_events = [e for e in events if e.event_type == "health"]
assert health_events[0].value == 0.0
def test_armor_normalized(self) -> None:
payload = _make_cs2_payload(armor=50)
events, _ = CS2Adapter.parse_payload(payload, {"adapter_id": "cs2_test"}, {})
armor_events = [e for e in events if e.event_type == "armor"]
assert len(armor_events) == 1
assert armor_events[0].value == pytest.approx(0.5)
def test_money_normalized(self) -> None:
payload = _make_cs2_payload(money=8000)
events, _ = CS2Adapter.parse_payload(payload, {"adapter_id": "cs2_test"}, {})
gold_events = [e for e in events if e.event_type == "gold"]
assert len(gold_events) == 1
assert gold_events[0].value == pytest.approx(8000.0 / 16000.0)
def test_ammo_normalized(self) -> None:
payload = _make_cs2_payload(ammo_clip=15, ammo_clip_max=30)
events, _ = CS2Adapter.parse_payload(payload, {"adapter_id": "cs2_test"}, {})
ammo_events = [e for e in events if e.event_type == "ammo"]
assert len(ammo_events) == 1
assert ammo_events[0].value == pytest.approx(0.5)
def test_adapter_id_passed_through(self) -> None:
payload = _make_cs2_payload(health=100)
events, _ = CS2Adapter.parse_payload(payload, {"adapter_id": "my_cs2"}, {})
assert all(e.adapter_id == "my_cs2" for e in events)
# ── Kill/Death diff detection tests ──────────────────────────────────────
class TestCS2DiffDetection:
def test_kill_detected_on_increase(self) -> None:
payload1 = _make_cs2_payload(kills=3)
_, state1 = CS2Adapter.parse_payload(payload1, {"adapter_id": "t"}, {})
payload2 = _make_cs2_payload(kills=4)
events, _ = CS2Adapter.parse_payload(payload2, {"adapter_id": "t"}, state1)
kill_events = [e for e in events if e.event_type == "kill"]
assert len(kill_events) == 1
def test_no_kill_on_first_payload(self) -> None:
payload = _make_cs2_payload(kills=5)
events, _ = CS2Adapter.parse_payload(payload, {"adapter_id": "t"}, {})
kill_events = [e for e in events if e.event_type == "kill"]
assert len(kill_events) == 0
def test_multiple_kills_detected(self) -> None:
payload1 = _make_cs2_payload(kills=2)
_, state1 = CS2Adapter.parse_payload(payload1, {"adapter_id": "t"}, {})
payload2 = _make_cs2_payload(kills=5)
events, _ = CS2Adapter.parse_payload(payload2, {"adapter_id": "t"}, state1)
kill_events = [e for e in events if e.event_type == "kill"]
assert len(kill_events) == 3
def test_death_detected(self) -> None:
payload1 = _make_cs2_payload(deaths=0)
_, state1 = CS2Adapter.parse_payload(payload1, {"adapter_id": "t"}, {})
payload2 = _make_cs2_payload(deaths=1)
events, _ = CS2Adapter.parse_payload(payload2, {"adapter_id": "t"}, state1)
death_events = [e for e in events if e.event_type == "death"]
assert len(death_events) == 1
def test_no_death_on_same_count(self) -> None:
payload1 = _make_cs2_payload(deaths=2)
_, state1 = CS2Adapter.parse_payload(payload1, {"adapter_id": "t"}, {})
payload2 = _make_cs2_payload(deaths=2)
events, _ = CS2Adapter.parse_payload(payload2, {"adapter_id": "t"}, state1)
death_events = [e for e in events if e.event_type == "death"]
assert len(death_events) == 0
# ── Round/Bomb trigger tests ────────────────────────────────────────────
class TestCS2Triggers:
def test_round_start(self) -> None:
payload = _make_cs2_payload(round_phase="live")
events, state = CS2Adapter.parse_payload(payload, {"adapter_id": "t"}, {})
rs = [e for e in events if e.event_type == "round_start"]
assert len(rs) == 1
assert state["round_phase"] == "live"
def test_round_end(self) -> None:
prev = {"round_phase": "live"}
payload = _make_cs2_payload(round_phase="over")
events, _ = CS2Adapter.parse_payload(payload, {"adapter_id": "t"}, prev)
re = [e for e in events if e.event_type == "round_end"]
assert len(re) == 1
def test_no_round_event_on_same_phase(self) -> None:
prev = {"round_phase": "live"}
payload = _make_cs2_payload(round_phase="live")
events, _ = CS2Adapter.parse_payload(payload, {"adapter_id": "t"}, prev)
round_events = [e for e in events if e.event_type in ("round_start", "round_end")]
assert len(round_events) == 0
def test_bomb_planted(self) -> None:
payload = _make_cs2_payload(round_phase="live", bomb="planted")
events, _ = CS2Adapter.parse_payload(payload, {"adapter_id": "t"}, {})
bomb = [e for e in events if e.event_type == "objective_captured"]
assert len(bomb) == 1
def test_bomb_defused(self) -> None:
prev = {"bomb": "planted"}
payload = _make_cs2_payload(round_phase="live", bomb="defused")
events, _ = CS2Adapter.parse_payload(payload, {"adapter_id": "t"}, prev)
defuse = [e for e in events if e.event_type == "objective_lost"]
assert len(defuse) == 1
def test_flashbang_detected(self) -> None:
payload = _make_cs2_payload(flashed=200)
events, _ = CS2Adapter.parse_payload(payload, {"adapter_id": "t"}, {"flashed": 0})
flash = [e for e in events if e.event_type == "blinded"]
assert len(flash) == 1
assert flash[0].value == pytest.approx(200.0 / 255.0)
def test_no_flashbang_when_already_flashed(self) -> None:
prev = {"flashed": 200}
payload = _make_cs2_payload(flashed=150)
events, _ = CS2Adapter.parse_payload(payload, {"adapter_id": "t"}, prev)
flash = [e for e in events if e.event_type == "blinded"]
# Already flashed (prev > 0), so no new flash event
assert len(flash) == 0
def test_team_ct(self) -> None:
payload = _make_cs2_payload(team="CT")
events, _ = CS2Adapter.parse_payload(payload, {"adapter_id": "t"}, {})
team = [e for e in events if e.event_type == "team_a"]
assert len(team) == 1
def test_team_t(self) -> None:
payload = _make_cs2_payload(team="T")
events, _ = CS2Adapter.parse_payload(payload, {"adapter_id": "t"}, {})
team = [e for e in events if e.event_type == "team_b"]
assert len(team) == 1
# ── Auth validation tests ───────────────────────────────────────────────
class TestCS2Auth:
def test_auth_valid(self) -> None:
payload = _make_cs2_payload(auth_token="mysecret")
result = CS2Adapter.validate_auth({}, payload, {"auth_token": "mysecret"})
assert result is True
def test_auth_invalid(self) -> None:
payload = _make_cs2_payload(auth_token="wrong")
result = CS2Adapter.validate_auth({}, payload, {"auth_token": "mysecret"})
assert result is False
def test_auth_no_token_configured_accepts_all(self) -> None:
payload = _make_cs2_payload()
result = CS2Adapter.validate_auth({}, payload, {})
assert result is True
def test_auth_missing_payload_token(self) -> None:
payload = _make_cs2_payload() # no auth in payload
result = CS2Adapter.validate_auth({}, payload, {"auth_token": "mysecret"})
assert result is False
# ── Metadata tests ──────────────────────────────────────────────────────
class TestCS2Metadata:
def test_adapter_type(self) -> None:
assert CS2Adapter.ADAPTER_TYPE == "cs2"
def test_supported_events(self) -> None:
assert "health" in CS2Adapter.SUPPORTED_EVENTS
assert "kill" in CS2Adapter.SUPPORTED_EVENTS
assert "death" in CS2Adapter.SUPPORTED_EVENTS
def test_config_schema(self) -> None:
schema = CS2Adapter.get_config_schema()
assert "auth_token" in schema["properties"]
def test_setup_instructions(self) -> None:
instructions = CS2Adapter.get_setup_instructions()
assert "gamestate_integration" in instructions
assert "CS2" in instructions
+204
View File
@@ -0,0 +1,204 @@
"""Tests for Dota 2 Game State Integration adapter."""
import pytest
from wled_controller.core.game_integration.adapters.dota2_adapter import Dota2Adapter
def _make_dota2_payload(
*,
health: int = 1000,
max_health: int = 1000,
mana: float = 500.0,
max_mana: float = 500.0,
gold: int = 625,
kills: int = 0,
deaths: int = 0,
game_state: str | None = None,
auth_token: str | None = None,
) -> dict:
"""Build a realistic Dota 2 GSI payload."""
payload: dict = {
"hero": {
"xpos": -6624,
"ypos": -6592,
"id": 1,
"name": "npc_dota_hero_antimage",
"level": 12,
"alive": True,
"respawn_seconds": 0,
"buyback_cost": 874,
"buyback_cooldown": 0,
"health": health,
"max_health": max_health,
"health_percent": int(100.0 * health / max_health) if max_health else 0,
"mana": mana,
"max_mana": max_mana,
"mana_percent": int(100.0 * mana / max_mana) if max_mana else 0,
},
"player": {
"steamid": "76561198012345678",
"name": "TestPlayer",
"activity": "playing",
"kills": kills,
"deaths": deaths,
"assists": 0,
"last_hits": 120,
"denies": 10,
"kill_streak": 0,
"gold": gold,
"gold_reliable": 200,
"gold_unreliable": gold - 200,
"gpm": 450,
"xpm": 520,
},
}
if game_state is not None:
payload["map"] = {
"name": "start",
"matchid": "12345",
"game_time": 1200,
"clock_time": 1200,
"daytime": True,
"game_state": game_state,
"win_team": "none",
}
if auth_token is not None:
payload["auth"] = {"token": auth_token}
return payload
class TestDota2ContinuousEvents:
def test_health_normalized(self) -> None:
payload = _make_dota2_payload(health=750, max_health=1500)
events, _ = Dota2Adapter.parse_payload(payload, {"adapter_id": "d2"}, {})
hp = [e for e in events if e.event_type == "health"]
assert len(hp) == 1
assert hp[0].value == pytest.approx(0.5)
def test_mana_normalized(self) -> None:
payload = _make_dota2_payload(mana=300.0, max_mana=600.0)
events, _ = Dota2Adapter.parse_payload(payload, {"adapter_id": "d2"}, {})
mp = [e for e in events if e.event_type == "mana"]
assert len(mp) == 1
assert mp[0].value == pytest.approx(0.5)
def test_gold_normalized(self) -> None:
payload = _make_dota2_payload(gold=5000)
events, _ = Dota2Adapter.parse_payload(payload, {"adapter_id": "d2"}, {})
g = [e for e in events if e.event_type == "gold"]
assert len(g) == 1
assert g[0].value == pytest.approx(5000.0 / 99999.0)
def test_gold_custom_max(self) -> None:
payload = _make_dota2_payload(gold=5000)
events, _ = Dota2Adapter.parse_payload(
payload,
{"adapter_id": "d2", "max_gold": 10000},
{},
)
g = [e for e in events if e.event_type == "gold"]
assert g[0].value == pytest.approx(0.5)
class TestDota2DiffDetection:
def test_kill_detected(self) -> None:
p1 = _make_dota2_payload(kills=3)
_, s1 = Dota2Adapter.parse_payload(p1, {"adapter_id": "d2"}, {})
p2 = _make_dota2_payload(kills=4)
events, _ = Dota2Adapter.parse_payload(p2, {"adapter_id": "d2"}, s1)
kills = [e for e in events if e.event_type == "kill"]
assert len(kills) == 1
def test_no_kill_on_first_payload(self) -> None:
p = _make_dota2_payload(kills=5)
events, _ = Dota2Adapter.parse_payload(p, {"adapter_id": "d2"}, {})
kills = [e for e in events if e.event_type == "kill"]
assert len(kills) == 0
def test_death_detected(self) -> None:
p1 = _make_dota2_payload(deaths=1)
_, s1 = Dota2Adapter.parse_payload(p1, {"adapter_id": "d2"}, {})
p2 = _make_dota2_payload(deaths=2)
events, _ = Dota2Adapter.parse_payload(p2, {"adapter_id": "d2"}, s1)
deaths = [e for e in events if e.event_type == "death"]
assert len(deaths) == 1
def test_multiple_kills(self) -> None:
p1 = _make_dota2_payload(kills=0)
_, s1 = Dota2Adapter.parse_payload(p1, {"adapter_id": "d2"}, {})
p2 = _make_dota2_payload(kills=3)
events, _ = Dota2Adapter.parse_payload(p2, {"adapter_id": "d2"}, s1)
kills = [e for e in events if e.event_type == "kill"]
assert len(kills) == 3
class TestDota2MatchFlow:
def test_match_start_pre_game(self) -> None:
p = _make_dota2_payload(game_state="DOTA_GAMERULES_STATE_PRE_GAME")
events, _ = Dota2Adapter.parse_payload(p, {"adapter_id": "d2"}, {})
ms = [e for e in events if e.event_type == "match_start"]
assert len(ms) == 1
def test_match_start_in_progress(self) -> None:
prev = {"game_state": "DOTA_GAMERULES_STATE_PRE_GAME"}
p = _make_dota2_payload(game_state="DOTA_GAMERULES_STATE_GAME_IN_PROGRESS")
events, _ = Dota2Adapter.parse_payload(p, {"adapter_id": "d2"}, prev)
ms = [e for e in events if e.event_type == "match_start"]
assert len(ms) == 1
def test_match_end(self) -> None:
prev = {"game_state": "DOTA_GAMERULES_STATE_GAME_IN_PROGRESS"}
p = _make_dota2_payload(game_state="DOTA_GAMERULES_STATE_POST_GAME")
events, _ = Dota2Adapter.parse_payload(p, {"adapter_id": "d2"}, prev)
me = [e for e in events if e.event_type == "match_end"]
assert len(me) == 1
def test_no_event_on_same_state(self) -> None:
prev = {"game_state": "DOTA_GAMERULES_STATE_GAME_IN_PROGRESS"}
p = _make_dota2_payload(game_state="DOTA_GAMERULES_STATE_GAME_IN_PROGRESS")
events, _ = Dota2Adapter.parse_payload(p, {"adapter_id": "d2"}, prev)
flow = [e for e in events if e.event_type in ("match_start", "match_end")]
assert len(flow) == 0
class TestDota2Auth:
def test_auth_valid(self) -> None:
p = _make_dota2_payload(auth_token="secret")
result = Dota2Adapter.validate_auth({}, p, {"auth_token": "secret"})
assert result is True
def test_auth_invalid(self) -> None:
p = _make_dota2_payload(auth_token="wrong")
result = Dota2Adapter.validate_auth({}, p, {"auth_token": "secret"})
assert result is False
def test_auth_no_config_accepts_all(self) -> None:
p = _make_dota2_payload()
result = Dota2Adapter.validate_auth({}, p, {})
assert result is True
class TestDota2Metadata:
def test_adapter_type(self) -> None:
assert Dota2Adapter.ADAPTER_TYPE == "dota2"
def test_supported_events(self) -> None:
assert "health" in Dota2Adapter.SUPPORTED_EVENTS
assert "mana" in Dota2Adapter.SUPPORTED_EVENTS
assert "gold" in Dota2Adapter.SUPPORTED_EVENTS
def test_config_schema(self) -> None:
schema = Dota2Adapter.get_config_schema()
assert "auth_token" in schema["properties"]
assert "max_gold" in schema["properties"]
def test_setup_instructions(self) -> None:
instructions = Dota2Adapter.get_setup_instructions()
assert "Dota 2" in instructions
+239
View File
@@ -0,0 +1,239 @@
"""Tests for GameEventBus — publish, subscribe, unsubscribe, thread safety."""
import threading
from wled_controller.core.game_integration.event_bus import GameEventBus
from wled_controller.core.game_integration.events import GameEvent
def _make_event(event_type: str = "health", value: float = 0.5) -> GameEvent:
return GameEvent(
adapter_id="test_adapter",
event_type=event_type,
value=value,
raw_data={"test": True},
)
class TestPublishSubscribe:
"""Basic publish/subscribe behavior."""
def test_subscribe_receives_matching_events(self) -> None:
bus = GameEventBus()
received: list[GameEvent] = []
bus.subscribe("health", received.append)
event = _make_event("health", 0.8)
bus.publish(event)
assert len(received) == 1
assert received[0] is event
def test_subscribe_ignores_non_matching_events(self) -> None:
bus = GameEventBus()
received: list[GameEvent] = []
bus.subscribe("health", received.append)
bus.publish(_make_event("kill"))
assert len(received) == 0
def test_subscribe_all_receives_every_event(self) -> None:
bus = GameEventBus()
received: list[GameEvent] = []
bus.subscribe_all(received.append)
bus.publish(_make_event("health"))
bus.publish(_make_event("kill"))
bus.publish(_make_event("death"))
assert len(received) == 3
def test_multiple_subscribers_same_type(self) -> None:
bus = GameEventBus()
received_a: list[GameEvent] = []
received_b: list[GameEvent] = []
bus.subscribe("kill", received_a.append)
bus.subscribe("kill", received_b.append)
bus.publish(_make_event("kill"))
assert len(received_a) == 1
assert len(received_b) == 1
def test_type_and_wildcard_both_receive(self) -> None:
bus = GameEventBus()
type_received: list[GameEvent] = []
wild_received: list[GameEvent] = []
bus.subscribe("health", type_received.append)
bus.subscribe_all(wild_received.append)
bus.publish(_make_event("health"))
assert len(type_received) == 1
assert len(wild_received) == 1
class TestUnsubscribe:
"""Unsubscribe behavior."""
def test_unsubscribe_stops_delivery(self) -> None:
bus = GameEventBus()
received: list[GameEvent] = []
sub_id = bus.subscribe("health", received.append)
bus.publish(_make_event("health"))
assert len(received) == 1
result = bus.unsubscribe(sub_id)
assert result is True
bus.publish(_make_event("health"))
assert len(received) == 1 # No new events
def test_unsubscribe_wildcard(self) -> None:
bus = GameEventBus()
received: list[GameEvent] = []
sub_id = bus.subscribe_all(received.append)
bus.publish(_make_event("health"))
assert len(received) == 1
bus.unsubscribe(sub_id)
bus.publish(_make_event("health"))
assert len(received) == 1
def test_unsubscribe_unknown_id_returns_false(self) -> None:
bus = GameEventBus()
assert bus.unsubscribe("nonexistent_id") is False
class TestRecentEvents:
"""Recent events deque."""
def test_get_recent_events_returns_published(self) -> None:
bus = GameEventBus()
bus.publish(_make_event("health", 0.5))
bus.publish(_make_event("kill", 1.0))
recent = bus.get_recent_events()
assert len(recent) == 2
assert recent[0].event_type == "health"
assert recent[1].event_type == "kill"
def test_get_recent_events_respects_limit(self) -> None:
bus = GameEventBus()
for i in range(10):
bus.publish(_make_event("health", i / 10.0))
recent = bus.get_recent_events(limit=3)
assert len(recent) == 3
def test_recent_events_bounded_by_maxlen(self) -> None:
bus = GameEventBus(recent_maxlen=5)
for i in range(10):
bus.publish(_make_event("health", i / 10.0))
recent = bus.get_recent_events(limit=100)
assert len(recent) == 5
class TestStats:
"""Event statistics."""
def test_stats_counts_per_type(self) -> None:
bus = GameEventBus()
bus.publish(_make_event("health"))
bus.publish(_make_event("health"))
bus.publish(_make_event("kill"))
stats = bus.get_stats()
assert stats["event_counts"]["health"] == 2
assert stats["event_counts"]["kill"] == 1
def test_stats_last_timestamp(self) -> None:
bus = GameEventBus()
assert bus.get_stats()["last_event_timestamp"] is None
event = _make_event("health")
bus.publish(event)
stats = bus.get_stats()
assert stats["last_event_timestamp"] == event.timestamp
class TestThreadSafety:
"""Concurrent publish/subscribe must not crash."""
def test_concurrent_publish(self) -> None:
bus = GameEventBus()
received: list[GameEvent] = []
lock = threading.Lock()
def safe_append(event: GameEvent) -> None:
with lock:
received.append(event)
bus.subscribe_all(safe_append)
num_threads = 10
events_per_thread = 50
threads = []
def publisher(thread_id: int) -> None:
for i in range(events_per_thread):
bus.publish(_make_event("health", i / events_per_thread))
for t_id in range(num_threads):
t = threading.Thread(target=publisher, args=(t_id,))
threads.append(t)
t.start()
for t in threads:
t.join(timeout=10)
assert len(received) == num_threads * events_per_thread
def test_concurrent_subscribe_unsubscribe(self) -> None:
"""Subscribe and unsubscribe from multiple threads simultaneously."""
bus = GameEventBus()
sub_ids: list[str] = []
lock = threading.Lock()
def subscriber() -> None:
sid = bus.subscribe("health", lambda e: None)
with lock:
sub_ids.append(sid)
threads = [threading.Thread(target=subscriber) for _ in range(20)]
for t in threads:
t.start()
for t in threads:
t.join(timeout=10)
assert len(sub_ids) == 20
# Unsubscribe all
for sid in sub_ids:
assert bus.unsubscribe(sid) is True
class TestCallbackError:
"""Callback errors must not crash the bus."""
def test_error_in_callback_does_not_block_others(self) -> None:
bus = GameEventBus()
received: list[GameEvent] = []
def bad_callback(event: GameEvent) -> None:
raise RuntimeError("boom")
bus.subscribe("health", bad_callback)
bus.subscribe("health", received.append)
# Should not raise
bus.publish(_make_event("health"))
# Second subscriber still receives the event
assert len(received) == 1
+492
View File
@@ -0,0 +1,492 @@
"""Tests for GameEventColorStripSource and GameEventColorStripStream."""
import time
from datetime import datetime, timezone
import numpy as np
from wled_controller.core.game_integration.event_bus import GameEventBus
from wled_controller.core.game_integration.events import GameEvent
from wled_controller.core.processing.game_event_stream import GameEventColorStripStream
from wled_controller.storage.bindable import BindableColor
from wled_controller.storage.color_strip_source import (
ColorStripSource,
GameEventColorStripSource,
)
# ── Helpers ──────────────────────────────────────────────────────────
def _make_source(**overrides) -> GameEventColorStripSource:
"""Create a GameEventColorStripSource with sensible defaults."""
defaults = dict(
id="css_test01",
name="Test Game Event Source",
source_type="game_event",
created_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
updated_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
game_integration_id="gi_abc12345",
idle_color=BindableColor([0, 0, 0]),
event_mappings=[
{
"event_type": "kill",
"effect": "flash",
"color": [255, 0, 0],
"duration_ms": 500,
"intensity": 1.0,
"priority": 1,
},
{
"event_type": "death",
"effect": "pulse",
"color": [0, 0, 255],
"duration_ms": 800,
"intensity": 0.8,
"priority": 2,
},
],
led_count=10,
)
defaults.update(overrides)
return GameEventColorStripSource(**defaults)
def _make_event(event_type: str = "kill", value: float = 1.0) -> GameEvent:
return GameEvent(
adapter_id="test_adapter",
event_type=event_type,
value=value,
)
# ── GameEventColorStripSource serialization tests ────────────────────
class TestGameEventColorStripSourceSerialization:
def test_to_dict_roundtrip(self):
source = _make_source()
d = source.to_dict()
assert d["source_type"] == "game_event"
assert d["game_integration_id"] == "gi_abc12345"
assert d["idle_color"] == [0, 0, 0]
assert len(d["event_mappings"]) == 2
assert d["led_count"] == 10
restored = GameEventColorStripSource.from_dict(d)
assert restored.id == source.id
assert restored.name == source.name
assert restored.source_type == "game_event"
assert restored.game_integration_id == "gi_abc12345"
assert restored.idle_color.color == [0, 0, 0]
assert len(restored.event_mappings) == 2
assert restored.led_count == 10
def test_from_dict_defaults(self):
data = {
"id": "css_min01",
"name": "Minimal",
"source_type": "game_event",
"created_at": "2026-01-01T00:00:00+00:00",
"updated_at": "2026-01-01T00:00:00+00:00",
}
source = GameEventColorStripSource.from_dict(data)
assert source.game_integration_id == ""
assert source.idle_color.color == [0, 0, 0]
assert source.event_mappings == []
assert source.led_count == 0
def test_factory_dispatch(self):
"""ColorStripSource.from_dict dispatches to GameEventColorStripSource."""
data = {
"id": "css_fac01",
"name": "Factory Test",
"source_type": "game_event",
"created_at": "2026-01-01T00:00:00+00:00",
"updated_at": "2026-01-01T00:00:00+00:00",
"game_integration_id": "gi_xyz",
}
source = ColorStripSource.from_dict(data)
assert isinstance(source, GameEventColorStripSource)
assert source.game_integration_id == "gi_xyz"
def test_create_from_kwargs(self):
source = GameEventColorStripSource.create_from_kwargs(
id="css_kw01",
name="KW Test",
source_type="game_event",
created_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
updated_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
game_integration_id="gi_123",
idle_color=[10, 20, 30],
event_mappings=[{"event_type": "kill", "effect": "flash"}],
led_count=20,
)
assert source.game_integration_id == "gi_123"
assert source.idle_color.color == [10, 20, 30]
assert len(source.event_mappings) == 1
assert source.led_count == 20
def test_create_instance_factory(self):
source = ColorStripSource.create_instance(
source_type="game_event",
id="css_ci01",
name="CI Test",
created_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
updated_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
game_integration_id="gi_ci",
)
assert isinstance(source, GameEventColorStripSource)
def test_sharable_is_false(self):
source = _make_source()
assert source.sharable is False
class TestGameEventColorStripSourceUpdate:
def test_apply_update_game_integration_id(self):
source = _make_source()
source.apply_update(game_integration_id="gi_new")
assert source.game_integration_id == "gi_new"
def test_apply_update_idle_color(self):
source = _make_source()
source.apply_update(idle_color=[100, 100, 100])
assert source.idle_color.color == [100, 100, 100]
def test_apply_update_event_mappings(self):
source = _make_source()
new_mappings = [{"event_type": "health", "effect": "breathing"}]
source.apply_update(event_mappings=new_mappings)
assert len(source.event_mappings) == 1
assert source.event_mappings[0]["event_type"] == "health"
def test_apply_update_led_count(self):
source = _make_source()
source.apply_update(led_count=50)
assert source.led_count == 50
def test_apply_update_ignores_none(self):
source = _make_source()
original_id = source.game_integration_id
source.apply_update(game_integration_id=None)
assert source.game_integration_id == original_id
# ── GameEventColorStripStream tests ──────────────────────────────────
class TestGameEventColorStripStreamLifecycle:
def test_start_stop(self):
source = _make_source()
bus = GameEventBus()
stream = GameEventColorStripStream(source, event_bus=bus)
stream.start()
assert stream._running is True
assert stream._thread is not None
stream.stop()
assert stream._running is False
assert stream._thread is None
# Subscriptions should be cleaned up
assert len(stream._subscription_ids) == 0
def test_double_start_is_safe(self):
source = _make_source()
bus = GameEventBus()
stream = GameEventColorStripStream(source, event_bus=bus)
stream.start()
thread1 = stream._thread
stream.start() # Should not create a second thread
assert stream._thread is thread1
stream.stop()
def test_subscriptions_registered(self):
source = _make_source()
bus = GameEventBus()
stream = GameEventColorStripStream(source, event_bus=bus)
stream.start()
# Should have subscribed to "kill" and "death"
assert len(stream._subscription_ids) == 2
stream.stop()
def test_no_event_bus_still_starts(self):
"""Stream should start even without an EventBus (just no events)."""
source = _make_source()
stream = GameEventColorStripStream(source, event_bus=None)
stream.start()
assert stream._running is True
stream.stop()
class TestGameEventColorStripStreamRendering:
def test_idle_outputs_idle_color(self):
source = _make_source(idle_color=BindableColor([50, 100, 150]))
bus = GameEventBus()
stream = GameEventColorStripStream(source, event_bus=bus)
stream.start()
# Wait for at least one render frame
time.sleep(0.1)
colors = stream.get_latest_colors()
assert colors is not None
assert colors.shape == (10, 3)
# Should be idle color
assert colors[0, 0] == 50
assert colors[0, 1] == 100
assert colors[0, 2] == 150
stream.stop()
def test_event_triggers_effect(self):
source = _make_source(
idle_color=BindableColor([0, 0, 0]),
event_mappings=[
{
"event_type": "kill",
"effect": "flash",
"color": [255, 0, 0],
"duration_ms": 2000,
"intensity": 1.0,
"priority": 1,
}
],
)
bus = GameEventBus()
stream = GameEventColorStripStream(source, event_bus=bus)
stream.start()
time.sleep(0.05)
# Fire a kill event
bus.publish(_make_event("kill"))
time.sleep(0.1)
colors = stream.get_latest_colors()
assert colors is not None
# Should have non-zero red (flash effect in progress)
assert colors[0, 0] > 0
stream.stop()
def test_unmatched_event_ignored(self):
source = _make_source(
idle_color=BindableColor([10, 10, 10]),
event_mappings=[
{
"event_type": "kill",
"effect": "flash",
"color": [255, 0, 0],
"duration_ms": 500,
"intensity": 1.0,
"priority": 1,
}
],
)
bus = GameEventBus()
stream = GameEventColorStripStream(source, event_bus=bus)
stream.start()
time.sleep(0.05)
# Fire an event type not in mappings
bus.publish(_make_event("health", value=0.5))
time.sleep(0.1)
colors = stream.get_latest_colors()
# Should still be idle color (no matching mapping)
assert colors[0, 0] == 10
assert colors[0, 1] == 10
assert colors[0, 2] == 10
stream.stop()
def test_priority_based_layering(self):
"""Higher priority effects override lower ones."""
source = _make_source(
idle_color=BindableColor([0, 0, 0]),
event_mappings=[
{
"event_type": "kill",
"effect": "flash",
"color": [255, 0, 0],
"duration_ms": 3000,
"intensity": 1.0,
"priority": 1,
},
{
"event_type": "death",
"effect": "flash",
"color": [0, 0, 255],
"duration_ms": 3000,
"intensity": 1.0,
"priority": 5,
},
],
)
bus = GameEventBus()
stream = GameEventColorStripStream(source, event_bus=bus)
stream.start()
time.sleep(0.05)
# Fire low-priority kill event
bus.publish(_make_event("kill"))
time.sleep(0.05)
# Fire high-priority death event — should override
bus.publish(_make_event("death"))
time.sleep(0.1)
colors = stream.get_latest_colors()
# Should be blue (death effect), not red (kill effect)
assert colors[0, 2] > colors[0, 0]
stream.stop()
def test_effect_returns_to_idle(self):
"""After effect completes, output returns to idle_color."""
source = _make_source(
idle_color=BindableColor([42, 42, 42]),
event_mappings=[
{
"event_type": "kill",
"effect": "flash",
"color": [255, 0, 0],
"duration_ms": 50, # Very short effect
"intensity": 1.0,
"priority": 1,
}
],
)
bus = GameEventBus()
stream = GameEventColorStripStream(source, event_bus=bus)
stream.start()
time.sleep(0.05)
bus.publish(_make_event("kill"))
# Wait for effect to complete
time.sleep(0.2)
colors = stream.get_latest_colors()
# Should be back to idle color
assert colors[0, 0] == 42
assert colors[0, 1] == 42
assert colors[0, 2] == 42
stream.stop()
class TestGameEventColorStripStreamEffects:
"""Test individual effect types produce non-zero output at mid-progress."""
def _fire_and_sample(self, effect: str, color: list) -> np.ndarray:
source = _make_source(
idle_color=BindableColor([0, 0, 0]),
event_mappings=[
{
"event_type": "kill",
"effect": effect,
"color": color,
"duration_ms": 2000,
"intensity": 1.0,
"priority": 1,
}
],
)
bus = GameEventBus()
stream = GameEventColorStripStream(source, event_bus=bus)
stream.start()
time.sleep(0.05)
bus.publish(_make_event("kill"))
# Sample in the first quarter of the effect
time.sleep(0.15)
colors = stream.get_latest_colors()
stream.stop()
return colors
def test_flash_produces_output(self):
colors = self._fire_and_sample("flash", [255, 0, 0])
assert colors[0, 0] > 0
def test_pulse_produces_output(self):
colors = self._fire_and_sample("pulse", [0, 255, 0])
assert colors[0, 1] > 0
def test_sweep_produces_output(self):
colors = self._fire_and_sample("sweep", [0, 0, 255])
# At least some LEDs should have non-zero blue
assert np.any(colors[:, 2] > 0)
def test_color_shift_produces_output(self):
colors = self._fire_and_sample("color_shift", [255, 128, 0])
# Should have some non-zero pixels
assert np.any(colors > 0)
def test_breathing_produces_output(self):
colors = self._fire_and_sample("breathing", [128, 0, 255])
# Breathing may start dim, but should have some output by 0.15s into a 2s effect
assert np.any(colors > 0)
class TestGameEventColorStripStreamConfigure:
def test_auto_size(self):
source = _make_source(led_count=0) # auto-size
stream = GameEventColorStripStream(source)
assert stream.led_count == 1 # minimum
stream.configure(60)
assert stream.led_count == 60
def test_fixed_size_not_overridden(self):
source = _make_source(led_count=30)
stream = GameEventColorStripStream(source)
assert stream.led_count == 30
stream.configure(60)
# Should NOT change because auto_size is False
assert stream.led_count == 30
class TestGameEventColorStripStreamUpdate:
def test_update_source_hot_reloads(self):
source = _make_source(
event_mappings=[
{
"event_type": "kill",
"effect": "flash",
"color": [255, 0, 0],
"duration_ms": 500,
"intensity": 1.0,
"priority": 1,
}
],
)
bus = GameEventBus()
stream = GameEventColorStripStream(source, event_bus=bus)
stream.start()
# Update with new mappings
updated_source = _make_source(
event_mappings=[
{
"event_type": "health",
"effect": "breathing",
"color": [0, 255, 0],
"duration_ms": 1000,
"intensity": 0.5,
"priority": 0,
}
],
)
stream.update_source(updated_source)
assert "health" in stream._mapping_lookup
assert "kill" not in stream._mapping_lookup
stream.stop()
@@ -0,0 +1,500 @@
"""Tests for GameEventValueSource (storage model) and GameEventValueStream (runtime)."""
import time
import threading
import pytest
from wled_controller.core.game_integration.event_bus import GameEventBus
from wled_controller.core.game_integration.events import GameEvent
from wled_controller.core.value_sources.game_event_value_source import GameEventValueStream
from wled_controller.storage.value_source import (
GameEventValueSource,
ValueSource,
)
# ---------------------------------------------------------------------------
# GameEventValueSource model tests (Task 5)
# ---------------------------------------------------------------------------
class TestGameEventValueSourceModel:
"""Serialization round-trips and defaults for the storage dataclass."""
def test_round_trip(self):
data = {
"id": "vs_game1",
"name": "Health Monitor",
"source_type": "game_event",
"game_integration_id": "gi_abc123",
"event_type": "health",
"min_game_value": 0.0,
"max_game_value": 100.0,
"smoothing": 0.3,
"default_value": 0.5,
"timeout": 10.0,
"created_at": "2025-01-01T00:00:00+00:00",
"updated_at": "2025-01-01T00:00:00+00:00",
}
src = ValueSource.from_dict(data)
assert isinstance(src, GameEventValueSource)
assert src.game_integration_id == "gi_abc123"
assert src.event_type == "health"
assert src.min_game_value == 0.0
assert src.max_game_value == 100.0
assert src.smoothing == 0.3
assert src.default_value == 0.5
assert src.timeout == 10.0
# Round-trip through to_dict -> from_dict
restored = ValueSource.from_dict(src.to_dict())
assert isinstance(restored, GameEventValueSource)
assert restored.game_integration_id == "gi_abc123"
assert restored.event_type == "health"
assert restored.smoothing == 0.3
assert restored.default_value == 0.5
assert restored.timeout == 10.0
def test_defaults(self):
data = {
"id": "vs_game2",
"name": "Default Test",
"source_type": "game_event",
"created_at": "2025-01-01T00:00:00+00:00",
"updated_at": "2025-01-01T00:00:00+00:00",
}
src = ValueSource.from_dict(data)
assert isinstance(src, GameEventValueSource)
assert src.game_integration_id == ""
assert src.event_type == "health"
assert src.min_game_value == 0.0
assert src.max_game_value == 100.0
assert src.smoothing == 0.0
assert src.default_value == 0.5
assert src.timeout == 5.0
def test_to_dict_includes_all_fields(self):
data = {
"id": "vs_game3",
"name": "Full",
"source_type": "game_event",
"game_integration_id": "gi_xyz",
"event_type": "ammo",
"min_game_value": 0.0,
"max_game_value": 30.0,
"smoothing": 0.5,
"default_value": 0.0,
"timeout": 3.0,
"created_at": "2025-01-01T00:00:00+00:00",
"updated_at": "2025-01-01T00:00:00+00:00",
}
src = ValueSource.from_dict(data)
d = src.to_dict()
assert d["source_type"] == "game_event"
assert d["game_integration_id"] == "gi_xyz"
assert d["event_type"] == "ammo"
assert d["max_game_value"] == 30.0
assert d["smoothing"] == 0.5
assert d["default_value"] == 0.0
assert d["timeout"] == 3.0
def test_from_dict_with_zero_values(self):
"""Ensure 0.0 values are preserved, not replaced by defaults."""
data = {
"id": "vs_game4",
"name": "Zeros",
"source_type": "game_event",
"min_game_value": 0.0,
"max_game_value": 0.0,
"smoothing": 0.0,
"default_value": 0.0,
"timeout": 0.0,
"created_at": "2025-01-01T00:00:00+00:00",
"updated_at": "2025-01-01T00:00:00+00:00",
}
src = ValueSource.from_dict(data)
assert isinstance(src, GameEventValueSource)
assert src.max_game_value == 0.0
assert src.default_value == 0.0
assert src.timeout == 0.0
# ---------------------------------------------------------------------------
# GameEventValueStream runtime tests (Task 6)
# ---------------------------------------------------------------------------
def _make_event(event_type: str, value: float) -> GameEvent:
return GameEvent(
adapter_id="test_adapter",
event_type=event_type,
value=value,
)
class TestGameEventValueStreamNormalization:
"""Tests for value normalization (min/max mapping)."""
def test_mid_value(self):
bus = GameEventBus()
stream = GameEventValueStream(
event_type="health",
min_game_value=0.0,
max_game_value=100.0,
event_bus=bus,
)
stream.start()
bus.publish(_make_event("health", 50.0))
assert stream.get_value() == pytest.approx(0.5)
stream.stop()
def test_min_value(self):
bus = GameEventBus()
stream = GameEventValueStream(
event_type="health",
min_game_value=0.0,
max_game_value=100.0,
event_bus=bus,
)
stream.start()
bus.publish(_make_event("health", 0.0))
assert stream.get_value() == pytest.approx(0.0)
stream.stop()
def test_max_value(self):
bus = GameEventBus()
stream = GameEventValueStream(
event_type="health",
min_game_value=0.0,
max_game_value=100.0,
event_bus=bus,
)
stream.start()
bus.publish(_make_event("health", 100.0))
assert stream.get_value() == pytest.approx(1.0)
stream.stop()
def test_clamp_above_max(self):
bus = GameEventBus()
stream = GameEventValueStream(
event_type="health",
min_game_value=0.0,
max_game_value=100.0,
event_bus=bus,
)
stream.start()
bus.publish(_make_event("health", 150.0))
assert stream.get_value() == pytest.approx(1.0)
stream.stop()
def test_clamp_below_min(self):
bus = GameEventBus()
stream = GameEventValueStream(
event_type="health",
min_game_value=0.0,
max_game_value=100.0,
event_bus=bus,
)
stream.start()
bus.publish(_make_event("health", -10.0))
assert stream.get_value() == pytest.approx(0.0)
stream.stop()
def test_custom_range(self):
bus = GameEventBus()
stream = GameEventValueStream(
event_type="ammo",
min_game_value=10.0,
max_game_value=60.0,
event_bus=bus,
)
stream.start()
bus.publish(_make_event("ammo", 35.0))
assert stream.get_value() == pytest.approx(0.5)
stream.stop()
def test_zero_range_returns_midpoint(self):
bus = GameEventBus()
stream = GameEventValueStream(
event_type="health",
min_game_value=50.0,
max_game_value=50.0,
event_bus=bus,
)
stream.start()
bus.publish(_make_event("health", 50.0))
assert stream.get_value() == pytest.approx(0.5)
stream.stop()
class TestGameEventValueStreamSmoothing:
"""Tests for EMA smoothing behavior."""
def test_no_smoothing(self):
bus = GameEventBus()
stream = GameEventValueStream(
event_type="health",
min_game_value=0.0,
max_game_value=100.0,
smoothing=0.0,
event_bus=bus,
)
stream.start()
bus.publish(_make_event("health", 100.0))
assert stream.get_value() == pytest.approx(1.0)
bus.publish(_make_event("health", 0.0))
assert stream.get_value() == pytest.approx(0.0)
stream.stop()
def test_smoothing_dampens_change(self):
bus = GameEventBus()
stream = GameEventValueStream(
event_type="health",
min_game_value=0.0,
max_game_value=100.0,
smoothing=0.8,
event_bus=bus,
)
stream.start()
# First event: no smoothing applied (no previous value)
bus.publish(_make_event("health", 100.0))
assert stream.get_value() == pytest.approx(1.0)
# Second event: smoothing kicks in
# alpha = 1 - 0.8 = 0.2
# smoothed = 0.2 * 0.0 + 0.8 * 1.0 = 0.8
bus.publish(_make_event("health", 0.0))
assert stream.get_value() == pytest.approx(0.8)
# Third event: continues smoothing
# smoothed = 0.2 * 0.0 + 0.8 * 0.8 = 0.64
bus.publish(_make_event("health", 0.0))
assert stream.get_value() == pytest.approx(0.64)
stream.stop()
def test_smoothing_converges(self):
"""With many events at the same value, output should approach that value."""
bus = GameEventBus()
stream = GameEventValueStream(
event_type="health",
min_game_value=0.0,
max_game_value=100.0,
smoothing=0.5,
event_bus=bus,
)
stream.start()
bus.publish(_make_event("health", 100.0))
for _ in range(20):
bus.publish(_make_event("health", 50.0))
assert stream.get_value() == pytest.approx(0.5, abs=0.01)
stream.stop()
class TestGameEventValueStreamTimeout:
"""Tests for timeout / revert to default."""
def test_default_before_any_event(self):
stream = GameEventValueStream(
event_type="health",
default_value=0.5,
event_bus=GameEventBus(),
)
stream.start()
assert stream.get_value() == pytest.approx(0.5)
stream.stop()
def test_timeout_reverts_to_default(self):
bus = GameEventBus()
stream = GameEventValueStream(
event_type="health",
min_game_value=0.0,
max_game_value=100.0,
default_value=0.5,
timeout=0.05, # 50ms timeout for fast test
event_bus=bus,
)
stream.start()
bus.publish(_make_event("health", 80.0))
assert stream.get_value() == pytest.approx(0.8)
# Wait for timeout
time.sleep(0.1)
assert stream.get_value() == pytest.approx(0.5)
stream.stop()
def test_no_timeout_when_zero(self):
bus = GameEventBus()
stream = GameEventValueStream(
event_type="health",
min_game_value=0.0,
max_game_value=100.0,
default_value=0.5,
timeout=0.0,
event_bus=bus,
)
stream.start()
bus.publish(_make_event("health", 80.0))
time.sleep(0.05)
# With timeout=0.0, should never revert
assert stream.get_value() == pytest.approx(0.8)
stream.stop()
def test_event_resets_timeout(self):
bus = GameEventBus()
stream = GameEventValueStream(
event_type="health",
min_game_value=0.0,
max_game_value=100.0,
default_value=0.5,
timeout=0.1,
event_bus=bus,
)
stream.start()
bus.publish(_make_event("health", 80.0))
time.sleep(0.06)
# Send another event before timeout
bus.publish(_make_event("health", 80.0))
time.sleep(0.06)
# Should still be live (timeout reset)
assert stream.get_value() == pytest.approx(0.8)
stream.stop()
class TestGameEventValueStreamLifecycle:
"""Tests for start/stop and EventBus subscription management."""
def test_stop_unsubscribes(self):
bus = GameEventBus()
stream = GameEventValueStream(
event_type="health",
min_game_value=0.0,
max_game_value=100.0,
default_value=0.5,
event_bus=bus,
)
stream.start()
bus.publish(_make_event("health", 100.0))
assert stream.get_value() == pytest.approx(1.0)
stream.stop()
# After stop, events should not affect the stream
assert stream.get_value() == pytest.approx(0.5)
def test_ignores_other_event_types(self):
bus = GameEventBus()
stream = GameEventValueStream(
event_type="health",
min_game_value=0.0,
max_game_value=100.0,
default_value=0.5,
event_bus=bus,
)
stream.start()
bus.publish(_make_event("ammo", 100.0))
# Should still be at default since event_type doesn't match
assert stream.get_value() == pytest.approx(0.5)
stream.stop()
def test_no_event_bus(self):
"""Stream without event bus should return default value."""
stream = GameEventValueStream(
event_type="health",
default_value=0.7,
event_bus=None,
)
stream.start()
assert stream.get_value() == pytest.approx(0.7)
stream.stop()
def test_get_color_raises(self):
stream = GameEventValueStream(
event_type="health",
event_bus=None,
)
with pytest.raises(NotImplementedError):
stream.get_color()
class TestGameEventValueStreamThreadSafety:
"""Tests for concurrent access from multiple threads."""
def test_concurrent_publish_and_read(self):
bus = GameEventBus()
stream = GameEventValueStream(
event_type="health",
min_game_value=0.0,
max_game_value=100.0,
default_value=0.5,
smoothing=0.0,
timeout=5.0,
event_bus=bus,
)
stream.start()
errors = []
def publisher():
try:
for i in range(100):
bus.publish(_make_event("health", float(i)))
except Exception as e:
errors.append(e)
def reader():
try:
for _ in range(100):
val = stream.get_value()
assert 0.0 <= val <= 1.0
except Exception as e:
errors.append(e)
threads = [
threading.Thread(target=publisher),
threading.Thread(target=reader),
threading.Thread(target=reader),
]
for t in threads:
t.start()
for t in threads:
t.join(timeout=5.0)
stream.stop()
assert len(errors) == 0, f"Thread errors: {errors}"
class TestGameEventValueStreamHotUpdate:
"""Tests for update_source hot-update functionality."""
def test_update_source_changes_params(self):
bus = GameEventBus()
stream = GameEventValueStream(
event_type="health",
min_game_value=0.0,
max_game_value=100.0,
default_value=0.5,
timeout=5.0,
event_bus=bus,
)
stream.start()
# Create an updated source config
source = GameEventValueSource(
id="vs_test",
name="Test",
source_type="game_event",
min_game_value=0.0,
max_game_value=200.0,
smoothing=0.5,
default_value=0.3,
timeout=10.0,
created_at="2025-01-01T00:00:00+00:00",
updated_at="2025-01-01T00:00:00+00:00",
)
stream.update_source(source)
# Verify hot-updated params take effect
bus.publish(_make_event("health", 100.0))
# With max_game_value=200, 100 normalizes to 0.5
assert stream.get_value() == pytest.approx(0.5)
stream.stop()
+75
View File
@@ -0,0 +1,75 @@
"""Tests for built-in effect presets."""
import pytest
from wled_controller.core.game_integration.presets import (
EffectPreset,
get_all_presets,
get_preset,
)
from wled_controller.storage.game_integration import EventMapping
class TestPresetData:
"""Verify preset data structure and contents."""
def test_get_all_presets_returns_list(self):
presets = get_all_presets()
assert isinstance(presets, list)
assert len(presets) >= 4
def test_each_preset_has_required_fields(self):
for preset in get_all_presets():
assert isinstance(preset, EffectPreset)
assert preset.key
assert preset.name
assert preset.description
assert len(preset.target_game_types) > 0
assert len(preset.event_mappings) > 0
def test_each_mapping_is_valid(self):
for preset in get_all_presets():
for mapping in preset.event_mappings:
assert isinstance(mapping, EventMapping)
assert mapping.event_type
assert mapping.effect
assert len(mapping.color) == 3
assert all(0 <= c <= 255 for c in mapping.color)
assert mapping.duration_ms > 0
assert 0.0 <= mapping.intensity <= 1.0
assert mapping.priority >= 0
def test_get_preset_by_key(self):
assert get_preset("fps_combat") is not None
assert get_preset("moba_health") is not None
assert get_preset("racing") is not None
assert get_preset("generic_alert") is not None
def test_get_preset_unknown_returns_none(self):
assert get_preset("nonexistent") is None
def test_fps_combat_has_expected_events(self):
preset = get_preset("fps_combat")
assert preset is not None
event_types = {m.event_type for m in preset.event_mappings}
assert "health" in event_types
assert "kill" in event_types
assert "death" in event_types
def test_moba_health_has_expected_events(self):
preset = get_preset("moba_health")
assert preset is not None
event_types = {m.event_type for m in preset.event_mappings}
assert "health" in event_types
assert "mana" in event_types
def test_presets_are_frozen(self):
preset = get_preset("fps_combat")
assert preset is not None
with pytest.raises(AttributeError):
preset.name = "Changed" # type: ignore[misc]
def test_preset_keys_unique(self):
presets = get_all_presets()
keys = [p.key for p in presets]
assert len(keys) == len(set(keys))
+47
View File
@@ -0,0 +1,47 @@
"""Tests verifying game integration wiring in ProcessorDependencies.
Ensures that GameEventBus is properly threaded through to
ColorStripStreamManager and ValueStreamManager.
"""
from wled_controller.core.game_integration.event_bus import GameEventBus
from wled_controller.core.processing.processor_manager import (
ProcessorDependencies,
ProcessorManager,
)
class TestGameEventBusWiring:
"""Verify that game_event_bus propagates through ProcessorManager."""
def test_processor_dependencies_accepts_game_event_bus(self):
bus = GameEventBus()
deps = ProcessorDependencies(game_event_bus=bus)
assert deps.game_event_bus is bus
def test_css_stream_manager_receives_bus(self):
bus = GameEventBus()
deps = ProcessorDependencies(game_event_bus=bus)
pm = ProcessorManager(deps)
css_mgr = pm._color_strip_stream_manager
assert css_mgr._game_event_bus is bus
def test_value_stream_manager_receives_bus(self):
"""ValueStreamManager is only created when value_source_store is provided."""
from unittest.mock import MagicMock
bus = GameEventBus()
mock_vs_store = MagicMock()
deps = ProcessorDependencies(
game_event_bus=bus,
value_source_store=mock_vs_store,
)
pm = ProcessorManager(deps)
vs_mgr = pm._value_stream_manager
assert vs_mgr is not None
assert vs_mgr._event_bus is bus
def test_without_bus_defaults_to_none(self):
deps = ProcessorDependencies()
pm = ProcessorManager(deps)
assert pm._color_strip_stream_manager._game_event_bus is None
@@ -0,0 +1,162 @@
"""Tests for Generic Webhook adapter."""
import pytest
from wled_controller.core.game_integration.adapters.generic_webhook_adapter import (
GenericWebhookAdapter,
)
class TestGenericWebhookParsing:
def test_basic_mapping(self) -> None:
config = {
"adapter_id": "webhook_test",
"mappings": [
{
"source_path": "player.health",
"event": "health",
"min": 0,
"max": 100,
"trigger": "on_value",
},
],
}
payload = {"player": {"health": 75}}
events, _ = GenericWebhookAdapter.parse_payload(payload, config, {})
assert len(events) == 1
assert events[0].event_type == "health"
assert events[0].value == pytest.approx(0.75)
def test_multiple_mappings(self) -> None:
config = {
"adapter_id": "webhook_test",
"mappings": [
{
"source_path": "hp",
"event": "health",
"min": 0,
"max": 100,
"trigger": "on_value",
},
{"source_path": "mp", "event": "mana", "min": 0, "max": 200, "trigger": "on_value"},
],
}
payload = {"hp": 50, "mp": 100}
events, _ = GenericWebhookAdapter.parse_payload(payload, config, {})
assert len(events) == 2
types = {e.event_type for e in events}
assert types == {"health", "mana"}
def test_empty_mappings(self) -> None:
config = {"adapter_id": "test", "mappings": []}
events, state = GenericWebhookAdapter.parse_payload({"hp": 50}, config, {})
assert len(events) == 0
def test_no_mappings_key(self) -> None:
config = {"adapter_id": "test"}
events, _ = GenericWebhookAdapter.parse_payload({"hp": 50}, config, {})
assert len(events) == 0
def test_diff_based_trigger(self) -> None:
config = {
"adapter_id": "test",
"mappings": [
{
"source_path": "kills",
"event": "kill",
"min": 0,
"max": 50,
"trigger": "on_increase",
},
],
}
# First call — establish baseline
_, state1 = GenericWebhookAdapter.parse_payload({"kills": 5}, config, {})
# Increase — should emit
events2, state2 = GenericWebhookAdapter.parse_payload({"kills": 6}, config, state1)
assert len(events2) == 1
assert events2[0].event_type == "kill"
# Same — should not emit
events3, _ = GenericWebhookAdapter.parse_payload({"kills": 6}, config, state2)
assert len(events3) == 0
def test_nested_json_path(self) -> None:
config = {
"adapter_id": "test",
"mappings": [
{
"source_path": "a.b.c",
"event": "health",
"min": 0,
"max": 10,
"trigger": "on_value",
},
],
}
payload = {"a": {"b": {"c": 5}}}
events, _ = GenericWebhookAdapter.parse_payload(payload, config, {})
assert len(events) == 1
assert events[0].value == pytest.approx(0.5)
class TestGenericWebhookAuth:
def test_no_auth_configured(self) -> None:
result = GenericWebhookAdapter.validate_auth({}, {}, {})
assert result is True
def test_bearer_auth_valid(self) -> None:
result = GenericWebhookAdapter.validate_auth(
{"Authorization": "Bearer secret123"},
{},
{"auth_token": "secret123"},
)
assert result is True
def test_bearer_auth_invalid(self) -> None:
result = GenericWebhookAdapter.validate_auth(
{"Authorization": "Bearer wrong"},
{},
{"auth_token": "secret123"},
)
assert result is False
def test_raw_token_auth(self) -> None:
result = GenericWebhookAdapter.validate_auth(
{"Authorization": "secret123"},
{},
{"auth_token": "secret123"},
)
assert result is True
def test_custom_header(self) -> None:
result = GenericWebhookAdapter.validate_auth(
{"X-Custom": "mytoken"},
{},
{"auth_token": "mytoken", "auth_header": "X-Custom"},
)
assert result is True
def test_missing_header(self) -> None:
result = GenericWebhookAdapter.validate_auth(
{},
{},
{"auth_token": "secret"},
)
assert result is False
class TestGenericWebhookMetadata:
def test_adapter_type(self) -> None:
assert GenericWebhookAdapter.ADAPTER_TYPE == "generic_webhook"
def test_config_schema(self) -> None:
schema = GenericWebhookAdapter.get_config_schema()
assert "auth_token" in schema["properties"]
assert "mappings" in schema["properties"]
assert "auth_header" in schema["properties"]
def test_setup_instructions(self) -> None:
instructions = GenericWebhookAdapter.get_setup_instructions()
assert "Webhook" in instructions
+183
View File
@@ -0,0 +1,183 @@
"""Tests for League of Legends Live Client Data API adapter."""
import threading
import pytest
from wled_controller.core.game_integration.adapters.lol_adapter import (
LoLAdapter,
LoLPoller,
)
def _make_lol_payload(
*,
current_health: float = 1000.0,
max_health: float = 1000.0,
resource_value: float = 500.0,
resource_max: float = 500.0,
level: int = 10,
summoner_name: str = "TestPlayer",
current_gold: float | None = None,
) -> dict:
"""Build a realistic LoL Live Client Data payload."""
payload: dict = {
"activePlayer": {
"summonerName": summoner_name,
"level": level,
"currentHealth": current_health,
"championStats": {
"currentHealth": current_health,
"maxHealth": max_health,
"resourceValue": resource_value,
"resourceMax": resource_max,
"attackDamage": 80.0,
"abilityPower": 0.0,
"armor": 60.0,
"magicResist": 40.0,
"moveSpeed": 345.0,
},
},
"allPlayers": [
{
"summonerName": summoner_name,
"championName": "Jinx",
"team": "ORDER",
"scores": {"kills": 5, "deaths": 2, "assists": 7},
},
],
"gameData": {
"gameMode": "CLASSIC",
"gameTime": 1200.5,
"mapName": "Map11",
"mapNumber": 11,
"mapTerrain": "Default",
},
}
if current_gold is not None:
payload["allPlayers"][0]["currentGold"] = current_gold
return payload
class TestLoLContinuousEvents:
def test_health_normalized(self) -> None:
payload = _make_lol_payload(current_health=500.0, max_health=1000.0)
events, _ = LoLAdapter.parse_payload(payload, {"adapter_id": "lol"}, {})
hp = [e for e in events if e.event_type == "health"]
assert len(hp) == 1
assert hp[0].value == pytest.approx(0.5)
def test_mana_normalized(self) -> None:
payload = _make_lol_payload(resource_value=300.0, resource_max=600.0)
events, _ = LoLAdapter.parse_payload(payload, {"adapter_id": "lol"}, {})
mp = [e for e in events if e.event_type == "mana"]
assert len(mp) == 1
assert mp[0].value == pytest.approx(0.5)
def test_level_normalized(self) -> None:
payload = _make_lol_payload(level=9)
events, _ = LoLAdapter.parse_payload(payload, {"adapter_id": "lol"}, {})
lvl = [e for e in events if e.event_type == "speed"]
assert len(lvl) == 1
assert lvl[0].value == pytest.approx(9.0 / 18.0)
def test_level_max(self) -> None:
payload = _make_lol_payload(level=18)
events, _ = LoLAdapter.parse_payload(payload, {"adapter_id": "lol"}, {})
lvl = [e for e in events if e.event_type == "speed"]
assert lvl[0].value == pytest.approx(1.0)
def test_gold(self) -> None:
payload = _make_lol_payload(current_gold=15000.0)
events, _ = LoLAdapter.parse_payload(payload, {"adapter_id": "lol"}, {})
gold = [e for e in events if e.event_type == "gold"]
assert len(gold) == 1
assert gold[0].value == pytest.approx(15000.0 / 30000.0)
class TestLoLDeathRespawn:
def test_death_detected_when_health_drops_to_zero(self) -> None:
prev = {"alive": True}
payload = _make_lol_payload(current_health=0.0, max_health=1000.0)
events, state = LoLAdapter.parse_payload(payload, {"adapter_id": "lol"}, prev)
deaths = [e for e in events if e.event_type == "death"]
assert len(deaths) == 1
assert state["alive"] is False
def test_no_death_when_already_dead(self) -> None:
prev = {"alive": False}
payload = _make_lol_payload(current_health=0.0, max_health=1000.0)
events, _ = LoLAdapter.parse_payload(payload, {"adapter_id": "lol"}, prev)
deaths = [e for e in events if e.event_type == "death"]
assert len(deaths) == 0
def test_respawn_detected(self) -> None:
prev = {"alive": False}
payload = _make_lol_payload(current_health=800.0, max_health=1000.0)
events, state = LoLAdapter.parse_payload(payload, {"adapter_id": "lol"}, prev)
respawns = [e for e in events if e.event_type == "objective_progress"]
assert len(respawns) == 1
assert state["alive"] is True
def test_no_respawn_when_already_alive(self) -> None:
prev = {"alive": True}
payload = _make_lol_payload(current_health=800.0, max_health=1000.0)
events, _ = LoLAdapter.parse_payload(payload, {"adapter_id": "lol"}, prev)
respawns = [e for e in events if e.event_type == "objective_progress"]
assert len(respawns) == 0
class TestLoLAuth:
def test_always_accepts(self) -> None:
assert LoLAdapter.validate_auth({}, {}, {}) is True
assert LoLAdapter.validate_auth({"X-Custom": "val"}, {}, {"auth_token": "x"}) is True
class TestLoLMetadata:
def test_adapter_type(self) -> None:
assert LoLAdapter.ADAPTER_TYPE == "lol"
def test_supported_events(self) -> None:
assert "health" in LoLAdapter.SUPPORTED_EVENTS
assert "mana" in LoLAdapter.SUPPORTED_EVENTS
assert "death" in LoLAdapter.SUPPORTED_EVENTS
def test_config_schema(self) -> None:
schema = LoLAdapter.get_config_schema()
assert "poll_interval_ms" in schema["properties"]
def test_setup_instructions(self) -> None:
instructions = LoLAdapter.get_setup_instructions()
assert "League of Legends" in instructions
class TestLoLPoller:
def test_start_stop(self) -> None:
"""Test that the poller starts and stops cleanly."""
called = threading.Event()
def callback(data: dict) -> None:
called.set()
poller = LoLPoller({"poll_interval_ms": 100}, callback)
assert not poller.is_running
poller.start()
assert poller.is_running
poller.stop()
assert not poller.is_running
def test_double_start_no_crash(self) -> None:
"""Starting twice should not create duplicate threads."""
poller = LoLPoller({"poll_interval_ms": 1000}, lambda d: None)
poller.start()
poller.start() # should warn but not crash
poller.stop()
def test_stop_without_start(self) -> None:
"""Stopping without starting should not crash."""
poller = LoLPoller({"poll_interval_ms": 1000}, lambda d: None)
poller.stop() # no-op
+469
View File
@@ -0,0 +1,469 @@
"""Tests for MappingAdapter — YAML parsing, payload translation, validation."""
import textwrap
from pathlib import Path
import pytest
from wled_controller.core.game_integration.mapping_adapter import (
MappingAdapter,
load_adapter_from_yaml,
validate_adapter_yaml,
)
# ── YAML validation tests ───────────────────────────────────────────────
class TestValidateAdapterYaml:
def test_valid_minimal(self) -> None:
data = {
"name": "test_adapter",
"game": "TestGame",
"protocol": "webhook",
"mappings": [
{"source_path": "player.health", "event": "health"},
],
}
errors = validate_adapter_yaml(data)
assert errors == []
def test_missing_name(self) -> None:
data = {
"game": "TestGame",
"protocol": "webhook",
"mappings": [{"source_path": "x", "event": "health"}],
}
errors = validate_adapter_yaml(data)
assert any("name" in e for e in errors)
def test_missing_game(self) -> None:
data = {
"name": "test",
"protocol": "webhook",
"mappings": [{"source_path": "x", "event": "health"}],
}
errors = validate_adapter_yaml(data)
assert any("game" in e for e in errors)
def test_invalid_protocol(self) -> None:
data = {
"name": "test",
"game": "TestGame",
"protocol": "invalid",
"mappings": [{"source_path": "x", "event": "health"}],
}
errors = validate_adapter_yaml(data)
assert any("protocol" in e for e in errors)
def test_empty_mappings(self) -> None:
data = {
"name": "test",
"game": "TestGame",
"protocol": "webhook",
"mappings": [],
}
errors = validate_adapter_yaml(data)
assert any("mappings" in e for e in errors)
def test_unknown_event_type(self) -> None:
data = {
"name": "test",
"game": "TestGame",
"protocol": "webhook",
"mappings": [{"source_path": "x", "event": "nonexistent_event"}],
}
errors = validate_adapter_yaml(data)
assert any("unknown event type" in e for e in errors)
def test_invalid_trigger_mode(self) -> None:
data = {
"name": "test",
"game": "TestGame",
"protocol": "webhook",
"mappings": [
{"source_path": "x", "event": "health", "trigger": "bad_mode"},
],
}
errors = validate_adapter_yaml(data)
assert any("trigger mode" in e for e in errors)
def test_non_numeric_min_max(self) -> None:
data = {
"name": "test",
"game": "TestGame",
"protocol": "webhook",
"mappings": [
{"source_path": "x", "event": "health", "min": "not_a_number"},
],
}
errors = validate_adapter_yaml(data)
assert any("'min' must be numeric" in e for e in errors)
# ── MappingAdapter payload parsing tests ─────────────────────────────────
class TestMappingAdapterParsePayload:
def _make_adapter(self, mappings: list[dict]) -> MappingAdapter:
return MappingAdapter(
{
"name": "test_adapter",
"game": "TestGame",
"protocol": "webhook",
"mappings": mappings,
}
)
def test_continuous_value_normalization(self) -> None:
adapter = self._make_adapter(
[
{
"source_path": "player.health",
"event": "health",
"min": 0,
"max": 100,
"trigger": "on_value",
},
]
)
payload = {"player": {"health": 75}}
events, state = adapter.parse_payload(payload, {"adapter_id": "test"}, {})
assert len(events) == 1
assert events[0].event_type == "health"
assert events[0].value == pytest.approx(0.75)
assert events[0].adapter_id == "test"
def test_value_clamped_to_0_1(self) -> None:
adapter = self._make_adapter(
[
{
"source_path": "val",
"event": "health",
"min": 0,
"max": 100,
"trigger": "on_value",
},
]
)
events_over, _ = adapter.parse_payload({"val": 150}, {"adapter_id": "t"}, {})
assert events_over[0].value == 1.0
events_under, _ = adapter.parse_payload({"val": -50}, {"adapter_id": "t"}, {})
assert events_under[0].value == 0.0
def test_on_change_trigger(self) -> None:
adapter = self._make_adapter(
[
{
"source_path": "kills",
"event": "kill",
"min": 0,
"max": 50,
"trigger": "on_change",
},
]
)
# First call: no prev_state, should emit
events1, state1 = adapter.parse_payload({"kills": 5}, {"adapter_id": "t"}, {})
assert len(events1) == 1
# Same value: should NOT emit
events2, state2 = adapter.parse_payload({"kills": 5}, {"adapter_id": "t"}, state1)
assert len(events2) == 0
# Changed value: should emit
events3, _ = adapter.parse_payload({"kills": 6}, {"adapter_id": "t"}, state2)
assert len(events3) == 1
def test_on_increase_trigger(self) -> None:
adapter = self._make_adapter(
[
{
"source_path": "kills",
"event": "kill",
"min": 0,
"max": 50,
"trigger": "on_increase",
},
]
)
# First call: prev_state empty, should NOT emit (no baseline)
events1, state1 = adapter.parse_payload({"kills": 5}, {"adapter_id": "t"}, {})
assert len(events1) == 0
# Decrease: should NOT emit
events2, state2 = adapter.parse_payload({"kills": 3}, {"adapter_id": "t"}, state1)
assert len(events2) == 0
# Increase: should emit
events3, _ = adapter.parse_payload({"kills": 7}, {"adapter_id": "t"}, state2)
assert len(events3) == 1
def test_on_decrease_trigger(self) -> None:
adapter = self._make_adapter(
[
{
"source_path": "hp",
"event": "damage_taken",
"min": 0,
"max": 100,
"trigger": "on_decrease",
},
]
)
_, state1 = adapter.parse_payload({"hp": 100}, {"adapter_id": "t"}, {})
events, _ = adapter.parse_payload({"hp": 80}, {"adapter_id": "t"}, state1)
assert len(events) == 1
assert events[0].event_type == "damage_taken"
def test_missing_path_skipped(self) -> None:
adapter = self._make_adapter(
[
{"source_path": "player.health", "event": "health", "trigger": "on_value"},
]
)
events, _ = adapter.parse_payload({"player": {}}, {"adapter_id": "t"}, {})
assert len(events) == 0
def test_non_numeric_value_emits_trigger(self) -> None:
adapter = self._make_adapter(
[
{"source_path": "status", "event": "buffed", "trigger": "on_value"},
]
)
events, _ = adapter.parse_payload({"status": "active"}, {"adapter_id": "t"}, {})
assert len(events) == 1
assert events[0].value == 1.0
def test_nested_json_path(self) -> None:
adapter = self._make_adapter(
[
{
"source_path": "a.b.c.d",
"event": "health",
"min": 0,
"max": 10,
"trigger": "on_value",
},
]
)
payload = {"a": {"b": {"c": {"d": 5}}}}
events, _ = adapter.parse_payload(payload, {"adapter_id": "t"}, {})
assert len(events) == 1
assert events[0].value == pytest.approx(0.5)
def test_multiple_mappings(self) -> None:
adapter = self._make_adapter(
[
{
"source_path": "hp",
"event": "health",
"min": 0,
"max": 100,
"trigger": "on_value",
},
{"source_path": "mp", "event": "mana", "min": 0, "max": 200, "trigger": "on_value"},
]
)
events, _ = adapter.parse_payload(
{"hp": 50, "mp": 100},
{"adapter_id": "t"},
{},
)
assert len(events) == 2
types = {e.event_type for e in events}
assert types == {"health", "mana"}
# ── Auth validation tests ────────────────────────────────────────────────
class TestMappingAdapterAuth:
def test_no_auth_accepts_all(self) -> None:
adapter = MappingAdapter(
{
"name": "test",
"game": "TestGame",
"protocol": "webhook",
"mappings": [{"source_path": "x", "event": "health"}],
}
)
assert adapter.validate_auth({}, {}, {}) is True
def test_header_auth_valid(self) -> None:
adapter = MappingAdapter(
{
"name": "test",
"game": "TestGame",
"protocol": "webhook",
"mappings": [{"source_path": "x", "event": "health"}],
"auth": {"type": "header", "header": "X-Auth-Token"},
}
)
result = adapter.validate_auth(
{"X-Auth-Token": "secret123"},
{},
{"auth_token": "secret123"},
)
assert result is True
def test_header_auth_invalid(self) -> None:
adapter = MappingAdapter(
{
"name": "test",
"game": "TestGame",
"protocol": "webhook",
"mappings": [{"source_path": "x", "event": "health"}],
"auth": {"type": "header", "header": "X-Auth-Token"},
}
)
result = adapter.validate_auth(
{"X-Auth-Token": "wrong"},
{},
{"auth_token": "secret123"},
)
assert result is False
# ── YAML file loading tests ──────────────────────────────────────────────
class TestLoadAdapterFromYaml:
def test_load_valid_yaml(self, tmp_path: Path) -> None:
yaml_content = textwrap.dedent(
"""\
name: cs2_gsi
game: "Counter-Strike 2"
protocol: webhook
mappings:
- source_path: player.state.health
event: health
min: 0
max: 100
- source_path: player.state.armor
event: armor
min: 0
max: 100
auth:
type: header
header: X-GSI-Auth
"""
)
yaml_file = tmp_path / "cs2.yaml"
yaml_file.write_text(yaml_content)
adapter = load_adapter_from_yaml(yaml_file)
assert adapter.name == "cs2_gsi"
assert adapter.game == "Counter-Strike 2"
assert adapter.protocol == "webhook"
assert "health" in adapter.supported_events
assert "armor" in adapter.supported_events
def test_load_nonexistent_file_raises(self) -> None:
with pytest.raises(FileNotFoundError):
load_adapter_from_yaml("/nonexistent/path.yaml")
def test_load_invalid_yaml_raises(self, tmp_path: Path) -> None:
yaml_file = tmp_path / "bad.yaml"
yaml_file.write_text("name: test\n") # Missing required fields
with pytest.raises(ValueError, match="Invalid adapter YAML"):
load_adapter_from_yaml(yaml_file)
def test_load_non_dict_yaml_raises(self, tmp_path: Path) -> None:
yaml_file = tmp_path / "list.yaml"
yaml_file.write_text("- item1\n- item2\n")
with pytest.raises(ValueError, match="must be a dict"):
load_adapter_from_yaml(yaml_file)
def test_loaded_adapter_parses_payload(self, tmp_path: Path) -> None:
yaml_content = textwrap.dedent(
"""\
name: test_game
game: TestGame
protocol: webhook
mappings:
- source_path: hp
event: health
min: 0
max: 100
trigger: on_value
"""
)
yaml_file = tmp_path / "test.yaml"
yaml_file.write_text(yaml_content)
adapter = load_adapter_from_yaml(yaml_file)
events, _ = adapter.parse_payload(
{"hp": 60},
{"adapter_id": "loaded_test"},
{},
)
assert len(events) == 1
assert events[0].value == pytest.approx(0.6)
# ── Properties and metadata tests ────────────────────────────────────────
class TestMappingAdapterMetadata:
def test_config_schema_with_auth(self) -> None:
adapter = MappingAdapter(
{
"name": "test",
"game": "TestGame",
"protocol": "webhook",
"mappings": [{"source_path": "x", "event": "health"}],
"auth": {"type": "header", "header": "X-Token"},
}
)
schema = adapter.get_config_schema()
assert "auth_token" in schema["properties"]
def test_config_schema_without_auth(self) -> None:
adapter = MappingAdapter(
{
"name": "test",
"game": "TestGame",
"protocol": "webhook",
"mappings": [{"source_path": "x", "event": "health"}],
}
)
schema = adapter.get_config_schema()
assert schema["properties"] == {}
def test_setup_instructions_from_yaml(self) -> None:
adapter = MappingAdapter(
{
"name": "test",
"game": "TestGame",
"protocol": "webhook",
"mappings": [{"source_path": "x", "event": "health"}],
"setup_instructions": "# Step 1\nDo this.",
}
)
assert "Step 1" in adapter.get_setup_instructions()
def test_setup_instructions_default(self) -> None:
adapter = MappingAdapter(
{
"name": "test",
"game": "TestGame",
"protocol": "webhook",
"mappings": [{"source_path": "x", "event": "health"}],
}
)
instructions = adapter.get_setup_instructions()
assert "TestGame" in instructions
+106 -77
View File
@@ -1,18 +1,17 @@
"""Tests for AutomationStore — CRUD, conditions, name uniqueness."""
"""Tests for AutomationStore — CRUD, rules, name uniqueness."""
import pytest
from wled_controller.storage.automation import (
AlwaysCondition,
ApplicationCondition,
ApplicationRule,
Automation,
Condition,
DisplayStateCondition,
MQTTCondition,
StartupCondition,
SystemIdleCondition,
TimeOfDayCondition,
WebhookCondition,
DisplayStateRule,
MQTTRule,
Rule,
StartupRule,
SystemIdleRule,
TimeOfDayRule,
WebhookRule,
)
from wled_controller.storage.automation_store import AutomationStore
@@ -23,72 +22,78 @@ def store(tmp_db) -> AutomationStore:
# ---------------------------------------------------------------------------
# Condition models
# Rule models
# ---------------------------------------------------------------------------
class TestConditionModels:
def test_always_round_trip(self):
c = AlwaysCondition()
data = c.to_dict()
restored = Condition.from_dict(data)
assert isinstance(restored, AlwaysCondition)
class TestRuleModels:
def test_application_round_trip(self):
c = ApplicationCondition(apps=["chrome.exe", "firefox.exe"], match_type="topmost")
data = c.to_dict()
restored = Condition.from_dict(data)
assert isinstance(restored, ApplicationCondition)
r = ApplicationRule(apps=["chrome.exe", "firefox.exe"], match_type="topmost")
data = r.to_dict()
restored = Rule.from_dict(data)
assert isinstance(restored, ApplicationRule)
assert restored.apps == ["chrome.exe", "firefox.exe"]
assert restored.match_type == "topmost"
def test_time_of_day_round_trip(self):
c = TimeOfDayCondition(start_time="22:00", end_time="06:00")
data = c.to_dict()
restored = Condition.from_dict(data)
assert isinstance(restored, TimeOfDayCondition)
r = TimeOfDayRule(start_time="22:00", end_time="06:00")
data = r.to_dict()
restored = Rule.from_dict(data)
assert isinstance(restored, TimeOfDayRule)
assert restored.start_time == "22:00"
assert restored.end_time == "06:00"
def test_system_idle_round_trip(self):
c = SystemIdleCondition(idle_minutes=10, when_idle=False)
data = c.to_dict()
restored = Condition.from_dict(data)
assert isinstance(restored, SystemIdleCondition)
r = SystemIdleRule(idle_minutes=10, when_idle=False)
data = r.to_dict()
restored = Rule.from_dict(data)
assert isinstance(restored, SystemIdleRule)
assert restored.idle_minutes == 10
assert restored.when_idle is False
def test_display_state_round_trip(self):
c = DisplayStateCondition(state="off")
data = c.to_dict()
restored = Condition.from_dict(data)
assert isinstance(restored, DisplayStateCondition)
r = DisplayStateRule(state="off")
data = r.to_dict()
restored = Rule.from_dict(data)
assert isinstance(restored, DisplayStateRule)
assert restored.state == "off"
def test_mqtt_round_trip(self):
c = MQTTCondition(topic="home/tv", payload="on", match_mode="contains")
data = c.to_dict()
restored = Condition.from_dict(data)
assert isinstance(restored, MQTTCondition)
r = MQTTRule(topic="home/tv", payload="on", match_mode="contains")
data = r.to_dict()
restored = Rule.from_dict(data)
assert isinstance(restored, MQTTRule)
assert restored.topic == "home/tv"
assert restored.match_mode == "contains"
def test_webhook_round_trip(self):
c = WebhookCondition(token="abc123")
data = c.to_dict()
restored = Condition.from_dict(data)
assert isinstance(restored, WebhookCondition)
r = WebhookRule(token="abc123")
data = r.to_dict()
restored = Rule.from_dict(data)
assert isinstance(restored, WebhookRule)
assert restored.token == "abc123"
def test_startup_round_trip(self):
c = StartupCondition()
data = c.to_dict()
restored = Condition.from_dict(data)
assert isinstance(restored, StartupCondition)
r = StartupRule()
data = r.to_dict()
restored = Rule.from_dict(data)
assert isinstance(restored, StartupRule)
def test_unknown_condition_type_raises(self):
with pytest.raises(ValueError, match="Unknown condition type"):
Condition.from_dict({"condition_type": "nonexistent"})
def test_unknown_rule_type_raises(self):
with pytest.raises(ValueError, match="Unknown rule type"):
Rule.from_dict({"rule_type": "nonexistent"})
def test_legacy_condition_type_migration(self):
"""Legacy data with condition_type should still deserialize."""
data = {"condition_type": "startup"}
restored = Rule.from_dict(data)
assert isinstance(restored, StartupRule)
def test_legacy_always_maps_to_startup(self):
"""Legacy 'always' condition_type should map to StartupRule."""
data = {"condition_type": "always"}
restored = Rule.from_dict(data)
assert isinstance(restored, StartupRule)
# ---------------------------------------------------------------------------
@@ -100,7 +105,7 @@ class TestAutomationModel:
def test_round_trip(self, make_automation):
auto = make_automation(
name="Test Auto",
conditions=[AlwaysCondition(), WebhookCondition(token="tok1")],
rules=[StartupRule(), WebhookRule(token="tok1")],
scene_preset_id="sp_123",
deactivation_mode="revert",
)
@@ -109,21 +114,21 @@ class TestAutomationModel:
assert restored.id == auto.id
assert restored.name == "Test Auto"
assert len(restored.conditions) == 2
assert isinstance(restored.conditions[0], AlwaysCondition)
assert isinstance(restored.conditions[1], WebhookCondition)
assert len(restored.rules) == 2
assert isinstance(restored.rules[0], StartupRule)
assert isinstance(restored.rules[1], WebhookRule)
assert restored.scene_preset_id == "sp_123"
assert restored.deactivation_mode == "revert"
def test_from_dict_skips_unknown_conditions(self):
def test_from_dict_skips_unknown_rules(self):
data = {
"id": "a1",
"name": "Skip",
"enabled": True,
"condition_logic": "or",
"conditions": [
{"condition_type": "always"},
{"condition_type": "future_unknown"},
"rule_logic": "or",
"rules": [
{"rule_type": "startup"},
{"rule_type": "future_unknown"},
],
"scene_preset_id": None,
"deactivation_mode": "none",
@@ -132,7 +137,30 @@ class TestAutomationModel:
"updated_at": "2025-01-01T00:00:00+00:00",
}
auto = Automation.from_dict(data)
assert len(auto.conditions) == 1 # unknown was skipped
assert len(auto.rules) == 1 # unknown was skipped
def test_legacy_conditions_field_migration(self):
"""Legacy data with 'conditions' and 'condition_logic' should migrate."""
data = {
"id": "a2",
"name": "Legacy",
"enabled": True,
"condition_logic": "and",
"conditions": [
{"condition_type": "startup"},
{"condition_type": "application", "apps": ["test.exe"], "match_type": "running"},
],
"scene_preset_id": None,
"deactivation_mode": "none",
"deactivation_scene_preset_id": None,
"created_at": "2025-01-01T00:00:00+00:00",
"updated_at": "2025-01-01T00:00:00+00:00",
}
auto = Automation.from_dict(data)
assert auto.rule_logic == "and"
assert len(auto.rules) == 2
assert isinstance(auto.rules[0], StartupRule)
assert isinstance(auto.rules[1], ApplicationRule)
# ---------------------------------------------------------------------------
@@ -146,27 +174,27 @@ class TestAutomationStoreCRUD:
assert a.id.startswith("auto_")
assert a.name == "Auto A"
assert a.enabled is True
assert a.condition_logic == "or"
assert a.rule_logic == "or"
assert store.count() == 1
def test_create_with_conditions(self, store):
conditions = [
AlwaysCondition(),
WebhookCondition(token="secret123"),
def test_create_with_rules(self, store):
rules = [
StartupRule(),
WebhookRule(token="secret123"),
]
a = store.create_automation(
name="Full",
enabled=False,
condition_logic="and",
conditions=conditions,
rule_logic="and",
rules=rules,
scene_preset_id="sp_001",
deactivation_mode="fallback_scene",
deactivation_scene_preset_id="sp_002",
tags=["test"],
)
assert a.enabled is False
assert a.condition_logic == "and"
assert len(a.conditions) == 2
assert a.rule_logic == "and"
assert len(a.rules) == 2
assert a.scene_preset_id == "sp_001"
assert a.tags == ["test"]
@@ -195,12 +223,12 @@ class TestAutomationStoreCRUD:
assert updated.name == "New"
assert updated.enabled is False
def test_update_conditions(self, store):
a = store.create_automation(name="Conds")
new_conds = [ApplicationCondition(apps=["notepad.exe"])]
updated = store.update_automation(a.id, conditions=new_conds)
assert len(updated.conditions) == 1
assert isinstance(updated.conditions[0], ApplicationCondition)
def test_update_rules(self, store):
a = store.create_automation(name="Rules")
new_rules = [ApplicationRule(apps=["notepad.exe"])]
updated = store.update_automation(a.id, rules=new_rules)
assert len(updated.rules) == 1
assert isinstance(updated.rules[0], ApplicationRule)
def test_update_scene_preset_id_clear(self, store):
a = store.create_automation(name="SP", scene_preset_id="sp_1")
@@ -241,17 +269,18 @@ class TestAutomationNameUniqueness:
class TestAutomationPersistence:
def test_persist_and_reload(self, tmp_path):
from wled_controller.storage.database import Database
db = Database(tmp_path / "auto_persist.db")
s1 = AutomationStore(db)
a = s1.create_automation(
name="Persist",
conditions=[WebhookCondition(token="t1")],
rules=[WebhookRule(token="t1")],
)
aid = a.id
s2 = AutomationStore(db)
loaded = s2.get_automation(aid)
assert loaded.name == "Persist"
assert len(loaded.conditions) == 1
assert isinstance(loaded.conditions[0], WebhookCondition)
assert len(loaded.rules) == 1
assert isinstance(loaded.rules[0], WebhookRule)
db.close()
@@ -0,0 +1,274 @@
"""Tests for GameIntegrationStore — CRUD, validation, uniqueness."""
import pytest
from wled_controller.storage.base_store import EntityNotFoundError
from wled_controller.storage.game_integration import EventMapping, GameIntegrationConfig
from wled_controller.storage.game_integration_store import GameIntegrationStore
@pytest.fixture
def store(tmp_db) -> GameIntegrationStore:
return GameIntegrationStore(tmp_db)
# ---------------------------------------------------------------------------
# Dataclass model tests
# ---------------------------------------------------------------------------
class TestEventMapping:
def test_round_trip(self):
m = EventMapping(
event_type="health",
effect="pulse",
color=[0, 255, 0],
duration_ms=1000,
intensity=0.8,
priority=5,
)
data = m.to_dict()
restored = EventMapping.from_dict(data)
assert restored.event_type == "health"
assert restored.effect == "pulse"
assert restored.color == [0, 255, 0]
assert restored.duration_ms == 1000
assert restored.intensity == 0.8
assert restored.priority == 5
def test_defaults(self):
m = EventMapping(event_type="kill")
assert m.effect == "flash"
assert m.color == [255, 0, 0]
assert m.duration_ms == 500
assert m.intensity == 1.0
assert m.priority == 0
def test_from_dict_defaults(self):
m = EventMapping.from_dict({"event_type": "death"})
assert m.effect == "flash"
assert m.color == [255, 0, 0]
class TestGameIntegrationConfig:
def test_round_trip(self):
config = GameIntegrationConfig.create_from_kwargs(
name="CS2 Integration",
adapter_type="cs2_gsi",
enabled=True,
adapter_config={"token": "secret123"},
event_mappings=[
EventMapping(event_type="health", effect="gradient", color=[255, 0, 0]),
EventMapping(event_type="kill", effect="flash", color=[0, 255, 0]),
],
description="Counter-Strike 2 game state integration",
tags=["fps", "cs2"],
)
data = config.to_dict()
restored = GameIntegrationConfig.from_dict(data)
assert restored.id == config.id
assert restored.name == "CS2 Integration"
assert restored.adapter_type == "cs2_gsi"
assert restored.enabled is True
assert restored.adapter_config == {"token": "secret123"}
assert len(restored.event_mappings) == 2
assert restored.event_mappings[0].event_type == "health"
assert restored.event_mappings[1].event_type == "kill"
assert restored.description == "Counter-Strike 2 game state integration"
assert restored.tags == ["fps", "cs2"]
def test_create_from_kwargs_generates_id(self):
config = GameIntegrationConfig.create_from_kwargs(name="Test", adapter_type="test")
assert config.id.startswith("gi_")
assert len(config.id) == 11 # gi_ + 8 hex chars
def test_apply_update_immutable(self):
original = GameIntegrationConfig.create_from_kwargs(
name="Original", adapter_type="test", enabled=True
)
updated = original.apply_update(name="Updated", enabled=False)
# Original unchanged
assert original.name == "Original"
assert original.enabled is True
# Updated has new values
assert updated.name == "Updated"
assert updated.enabled is False
assert updated.id == original.id
assert updated.created_at == original.created_at
assert updated.updated_at >= original.updated_at
def test_apply_update_partial(self):
original = GameIntegrationConfig.create_from_kwargs(
name="Test", adapter_type="test", description="original desc"
)
updated = original.apply_update(description="new desc")
assert updated.name == "Test"
assert updated.adapter_type == "test"
assert updated.description == "new desc"
# ---------------------------------------------------------------------------
# Store CRUD tests
# ---------------------------------------------------------------------------
class TestGameIntegrationStoreCRUD:
def test_create_and_get(self, store):
config = store.create_integration(
name="My Integration",
adapter_type="webhook",
description="Test integration",
)
assert config.id.startswith("gi_")
assert config.name == "My Integration"
assert config.adapter_type == "webhook"
assert config.enabled is True
fetched = store.get_integration(config.id)
assert fetched.name == "My Integration"
def test_list_all(self, store):
store.create_integration(name="Int 1", adapter_type="webhook")
store.create_integration(name="Int 2", adapter_type="cs2_gsi")
all_configs = store.get_all_integrations()
assert len(all_configs) == 2
names = {c.name for c in all_configs}
assert names == {"Int 1", "Int 2"}
def test_update(self, store):
config = store.create_integration(
name="Old Name",
adapter_type="webhook",
)
updated = store.update_integration(
config.id,
name="New Name",
enabled=False,
description="Updated description",
)
assert updated.name == "New Name"
assert updated.enabled is False
assert updated.description == "Updated description"
assert updated.updated_at > config.updated_at
def test_update_event_mappings(self, store):
config = store.create_integration(
name="Test",
adapter_type="webhook",
event_mappings=[EventMapping(event_type="health")],
)
new_mappings = [
EventMapping(event_type="kill", effect="flash", color=[0, 255, 0]),
EventMapping(event_type="death", effect="pulse", color=[255, 0, 0]),
]
updated = store.update_integration(config.id, event_mappings=new_mappings)
assert len(updated.event_mappings) == 2
assert updated.event_mappings[0].event_type == "kill"
assert updated.event_mappings[1].event_type == "death"
def test_delete(self, store):
config = store.create_integration(name="ToDelete", adapter_type="webhook")
store.delete_integration(config.id)
with pytest.raises(EntityNotFoundError):
store.get_integration(config.id)
def test_delete_nonexistent(self, store):
with pytest.raises(EntityNotFoundError):
store.delete_integration("gi_nonexist")
def test_get_nonexistent(self, store):
with pytest.raises(EntityNotFoundError):
store.get_integration("gi_nonexist")
def test_create_with_adapter_config(self, store):
config = store.create_integration(
name="CS2",
adapter_type="cs2_gsi",
adapter_config={"auth_token": "secret", "port": 3000},
)
fetched = store.get_integration(config.id)
assert fetched.adapter_config == {"auth_token": "secret", "port": 3000}
# ---------------------------------------------------------------------------
# Name uniqueness tests
# ---------------------------------------------------------------------------
class TestNameUniqueness:
def test_duplicate_name_rejected(self, store):
store.create_integration(name="Unique Name", adapter_type="webhook")
with pytest.raises(ValueError, match="already exists"):
store.create_integration(name="Unique Name", adapter_type="webhook")
def test_empty_name_rejected(self, store):
with pytest.raises(ValueError, match="required"):
store.create_integration(name="", adapter_type="webhook")
def test_whitespace_name_rejected(self, store):
with pytest.raises(ValueError, match="required"):
store.create_integration(name=" ", adapter_type="webhook")
def test_update_same_name_allowed(self, store):
config = store.create_integration(name="Same", adapter_type="webhook")
updated = store.update_integration(config.id, name="Same")
assert updated.name == "Same"
def test_update_to_existing_name_rejected(self, store):
store.create_integration(name="Name A", adapter_type="webhook")
config_b = store.create_integration(name="Name B", adapter_type="webhook")
with pytest.raises(ValueError, match="already exists"):
store.update_integration(config_b.id, name="Name A")
# ---------------------------------------------------------------------------
# Persistence tests
# ---------------------------------------------------------------------------
class TestPersistence:
def test_survives_reload(self, tmp_db):
store1 = GameIntegrationStore(tmp_db)
config = store1.create_integration(
name="Persistent",
adapter_type="webhook",
event_mappings=[EventMapping(event_type="health", effect="gradient")],
tags=["test"],
)
# Create a new store instance (simulates restart)
store2 = GameIntegrationStore(tmp_db)
fetched = store2.get_integration(config.id)
assert fetched.name == "Persistent"
assert fetched.adapter_type == "webhook"
assert len(fetched.event_mappings) == 1
assert fetched.event_mappings[0].event_type == "health"
assert fetched.tags == ["test"]
# ---------------------------------------------------------------------------
# get_references tests
# ---------------------------------------------------------------------------
class TestGetReferences:
def test_returns_empty(self, store):
config = store.create_integration(name="Test", adapter_type="webhook")
refs = store.get_references(config.id)
assert refs == []