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:
@@ -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"
|
||||
)
|
||||
Reference in New Issue
Block a user