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