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"
)
@@ -1,4 +1,4 @@
"""Automation engine — background loop that evaluates conditions and activates scenes."""
"""Automation engine — background loop that evaluates rules and activates scenes."""
import asyncio
import re
@@ -7,17 +7,16 @@ from typing import Dict, Optional, Set
from wled_controller.core.automations.platform_detector import PlatformDetector
from wled_controller.storage.automation import (
AlwaysCondition,
ApplicationCondition,
ApplicationRule,
Automation,
Condition,
DisplayStateCondition,
HomeAssistantCondition,
MQTTCondition,
StartupCondition,
SystemIdleCondition,
TimeOfDayCondition,
WebhookCondition,
DisplayStateRule,
HomeAssistantRule,
MQTTRule,
Rule,
StartupRule,
SystemIdleRule,
TimeOfDayRule,
WebhookRule,
)
from wled_controller.storage.automation_store import AutomationStore
from wled_controller.storage.scene_preset import ScenePreset
@@ -27,7 +26,7 @@ logger = get_logger(__name__)
class AutomationEngine:
"""Evaluates automation conditions and activates/deactivates scene presets."""
"""Evaluates automation rules and activates/deactivates scene presets."""
def __init__(
self,
@@ -62,10 +61,13 @@ class AutomationEngine:
self._last_deactivated: Dict[str, datetime] = {}
# webhook_token → bool (volatile state set by webhook calls)
self._webhook_states: Dict[str, bool] = {}
# HA source IDs currently acquired by the engine
self._ha_acquired: Set[str] = set()
async def start(self) -> None:
if self._task is not None:
return
await self._sync_ha_runtimes()
self._task = asyncio.create_task(self._poll_loop())
logger.info("Automation engine started")
@@ -85,8 +87,55 @@ class AutomationEngine:
for automation_id in list(self._active_automations.keys()):
await self._deactivate_automation(automation_id)
# Release all HA runtimes
await self._release_all_ha_runtimes()
logger.info("Automation engine stopped")
def _get_needed_ha_sources(self) -> Set[str]:
"""Collect HA source IDs referenced by enabled automations."""
needed: Set[str] = set()
if self._ha_manager is None:
return needed
for a in self._store.get_all_automations():
if a.enabled:
for r in a.rules:
if isinstance(r, HomeAssistantRule) and r.ha_source_id:
needed.add(r.ha_source_id)
return needed
async def _sync_ha_runtimes(self) -> None:
"""Acquire/release HA runtimes to match current automation rules."""
if self._ha_manager is None:
return
needed = self._get_needed_ha_sources()
# Release sources no longer needed
for source_id in self._ha_acquired - needed:
try:
await self._ha_manager.release(source_id)
logger.debug("Released HA runtime for automation: %s", source_id)
except Exception as e:
logger.warning("Failed to release HA runtime %s: %s", source_id, e)
# Acquire newly needed sources
for source_id in needed - self._ha_acquired:
try:
await self._ha_manager.acquire(source_id)
logger.debug("Acquired HA runtime for automation: %s", source_id)
except Exception as e:
logger.warning("Failed to acquire HA runtime %s: %s", source_id, e)
self._ha_acquired = needed
async def _release_all_ha_runtimes(self) -> None:
"""Release all HA runtimes held by the engine."""
if self._ha_manager is None:
return
for source_id in self._ha_acquired:
try:
await self._ha_manager.release(source_id)
except Exception as e:
logger.warning("Failed to release HA runtime %s: %s", source_id, e)
self._ha_acquired = set()
async def _poll_loop(self) -> None:
try:
while True:
@@ -100,6 +149,7 @@ class AutomationEngine:
pass
async def _evaluate_all(self) -> None:
await self._sync_ha_runtimes()
async with self._eval_lock:
await self._evaluate_all_locked()
@@ -152,12 +202,12 @@ class AutomationEngine:
needs_display_state = False
for a in automations:
if a.enabled:
for c in a.conditions:
if isinstance(c, ApplicationCondition):
match_types_used.add(c.match_type)
elif isinstance(c, SystemIdleCondition):
for r in a.rules:
if isinstance(r, ApplicationRule):
match_types_used.add(r.match_type)
elif isinstance(r, SystemIdleRule):
needs_idle = True
elif isinstance(c, DisplayStateCondition):
elif isinstance(r, DisplayStateRule):
needs_display_state = True
needs_running = "running" in match_types_used
@@ -187,8 +237,8 @@ class AutomationEngine:
for automation in automations:
should_be_active = automation.enabled and (
len(automation.conditions) == 0
or self._evaluate_conditions(
len(automation.rules) == 0
or self._evaluate_rules(
automation,
running_procs,
topmost_proc,
@@ -214,7 +264,7 @@ class AutomationEngine:
if aid not in active_automation_ids:
await self._deactivate_automation(aid)
def _evaluate_conditions(
def _evaluate_rules(
self,
automation: Automation,
running_procs: Set[str],
@@ -225,8 +275,8 @@ class AutomationEngine:
display_state: Optional[str],
) -> bool:
results = [
self._evaluate_condition(
c,
self._evaluate_rule(
r,
running_procs,
topmost_proc,
topmost_fullscreen,
@@ -234,16 +284,16 @@ class AutomationEngine:
idle_seconds,
display_state,
)
for c in automation.conditions
for r in automation.rules
]
if automation.condition_logic == "and":
if automation.rule_logic == "and":
return all(results)
return any(results) # "or" is default
def _evaluate_condition(
def _evaluate_rule(
self,
condition: Condition,
rule: Rule,
running_procs: Set[str],
topmost_proc: Optional[str],
topmost_fullscreen: bool,
@@ -252,29 +302,28 @@ class AutomationEngine:
display_state: Optional[str],
) -> bool:
dispatch = {
AlwaysCondition: lambda c: True,
StartupCondition: lambda c: True,
ApplicationCondition: lambda c: self._evaluate_app_condition(
c, running_procs, topmost_proc, topmost_fullscreen, fullscreen_procs
StartupRule: lambda r: True,
ApplicationRule: lambda r: self._evaluate_app_rule(
r, running_procs, topmost_proc, topmost_fullscreen, fullscreen_procs
),
TimeOfDayCondition: lambda c: self._evaluate_time_of_day(c),
SystemIdleCondition: lambda c: self._evaluate_idle(c, idle_seconds),
DisplayStateCondition: lambda c: self._evaluate_display_state(c, display_state),
MQTTCondition: lambda c: self._evaluate_mqtt(c),
WebhookCondition: lambda c: self._webhook_states.get(c.token, False),
HomeAssistantCondition: lambda c: self._evaluate_home_assistant(c),
TimeOfDayRule: lambda r: self._evaluate_time_of_day(r),
SystemIdleRule: lambda r: self._evaluate_idle(r, idle_seconds),
DisplayStateRule: lambda r: self._evaluate_display_state(r, display_state),
MQTTRule: lambda r: self._evaluate_mqtt(r),
WebhookRule: lambda r: self._webhook_states.get(r.token, False),
HomeAssistantRule: lambda r: self._evaluate_home_assistant(r),
}
handler = dispatch.get(type(condition))
handler = dispatch.get(type(rule))
if handler is None:
return False
return handler(condition)
return handler(rule)
@staticmethod
def _evaluate_time_of_day(condition: TimeOfDayCondition) -> bool:
def _evaluate_time_of_day(rule: TimeOfDayRule) -> bool:
now = datetime.now()
current = now.hour * 60 + now.minute
parts_s = condition.start_time.split(":")
parts_e = condition.end_time.split(":")
parts_s = rule.start_time.split(":")
parts_e = rule.end_time.split(":")
start = int(parts_s[0]) * 60 + int(parts_s[1])
end = int(parts_e[0]) * 60 + int(parts_e[1])
if start <= end:
@@ -283,73 +332,71 @@ class AutomationEngine:
return current >= start or current <= end
@staticmethod
def _evaluate_idle(condition: SystemIdleCondition, idle_seconds: Optional[float]) -> bool:
def _evaluate_idle(rule: SystemIdleRule, idle_seconds: Optional[float]) -> bool:
if idle_seconds is None:
return False
is_idle = idle_seconds >= (condition.idle_minutes * 60)
return is_idle if condition.when_idle else not is_idle
is_idle = idle_seconds >= (rule.idle_minutes * 60)
return is_idle if rule.when_idle else not is_idle
@staticmethod
def _evaluate_display_state(
condition: DisplayStateCondition, display_state: Optional[str]
) -> bool:
def _evaluate_display_state(rule: DisplayStateRule, display_state: Optional[str]) -> bool:
if display_state is None:
return False
return display_state == condition.state
return display_state == rule.state
def _evaluate_mqtt(self, condition: MQTTCondition) -> bool:
def _evaluate_mqtt(self, rule: MQTTRule) -> bool:
if self._mqtt_service is None or not self._mqtt_service.is_connected:
return False
value = self._mqtt_service.get_last_value(condition.topic)
value = self._mqtt_service.get_last_value(rule.topic)
if value is None:
return False
matchers = {
"exact": lambda: value == condition.payload,
"contains": lambda: condition.payload in value,
"regex": lambda: bool(re.search(condition.payload, value)),
"exact": lambda: value == rule.payload,
"contains": lambda: rule.payload in value,
"regex": lambda: bool(re.search(rule.payload, value)),
}
matcher = matchers.get(condition.match_mode)
matcher = matchers.get(rule.match_mode)
if matcher is None:
return False
try:
return matcher()
except re.error as e:
logger.debug("MQTT condition regex error: %s", e)
logger.debug("MQTT rule regex error: %s", e)
return False
def _evaluate_home_assistant(self, condition: HomeAssistantCondition) -> bool:
def _evaluate_home_assistant(self, rule: HomeAssistantRule) -> bool:
if self._ha_manager is None:
return False
entity_state = self._ha_manager.get_state(condition.ha_source_id, condition.entity_id)
entity_state = self._ha_manager.get_state(rule.ha_source_id, rule.entity_id)
if entity_state is None:
return False
value = entity_state.state
matchers = {
"exact": lambda: value == condition.state,
"contains": lambda: condition.state in value,
"regex": lambda: bool(re.search(condition.state, value)),
"exact": lambda: value == rule.state,
"contains": lambda: rule.state in value,
"regex": lambda: bool(re.search(rule.state, value)),
}
matcher = matchers.get(condition.match_mode)
matcher = matchers.get(rule.match_mode)
if matcher is None:
return False
try:
return matcher()
except re.error as e:
logger.debug("HA condition regex error: %s", e)
logger.debug("HA rule regex error: %s", e)
return False
def _evaluate_app_condition(
def _evaluate_app_rule(
self,
condition: ApplicationCondition,
rule: ApplicationRule,
running_procs: Set[str],
topmost_proc: Optional[str],
topmost_fullscreen: bool,
fullscreen_procs: Set[str],
) -> bool:
if not condition.apps:
if not rule.apps:
return False
apps_lower = [a.lower() for a in condition.apps]
apps_lower = [a.lower() for a in rule.apps]
match_handlers = {
"fullscreen": lambda: any(app in fullscreen_procs for app in apps_lower),
@@ -362,7 +409,7 @@ class AutomationEngine:
topmost_proc is not None and any(app == topmost_proc for app in apps_lower)
),
}
handler = match_handlers.get(condition.match_type)
handler = match_handlers.get(rule.match_type)
if handler is not None:
return handler()
# Default: "running"
@@ -370,7 +417,7 @@ class AutomationEngine:
async def _activate_automation(self, automation: Automation) -> None:
if not automation.scene_preset_id:
# No scene configured — just mark active (conditions matched but nothing to do)
# No scene configured — just mark active (rules matched but nothing to do)
self._active_automations[automation.id] = True
self._last_activated[automation.id] = datetime.now(timezone.utc)
self._fire_event(automation.id, "activated")
@@ -526,7 +573,7 @@ class AutomationEngine:
logger.error(f"Immediate automation evaluation error: {e}", exc_info=True)
async def set_webhook_state(self, token: str, active: bool) -> None:
"""Set webhook condition state and trigger immediate evaluation."""
"""Set webhook rule state and trigger immediate evaluation."""
self._webhook_states[token] = active
await self.trigger_evaluate()
@@ -5,7 +5,6 @@ from typing import Any, Dict, List, Optional
import numpy as np
from wled_controller.config import is_demo_mode
from wled_controller.core.capture_engines.base import (
CaptureEngine,
CaptureStream,
@@ -16,6 +15,8 @@ from wled_controller.utils import get_logger
logger = get_logger(__name__)
SIMULATION_TYPES = ["radial_gradient"]
# Virtual display definitions: (name, width, height, x, y, is_primary)
_VIRTUAL_DISPLAYS = [
("Demo Display 1080p", 1920, 1080, 0, 360, True),
@@ -35,6 +36,7 @@ class DemoCaptureStream(CaptureStream):
def __init__(self, display_index: int, config: Dict[str, Any]):
super().__init__(display_index, config)
self._simulation_type: str = config.get("simulation_type", "radial_gradient")
self._width: int = config.get("width", 1920)
self._height: int = config.get("height", 1080)
# Pre-compute at render resolution
@@ -50,7 +52,7 @@ class DemoCaptureStream(CaptureStream):
self._yy, self._xx = np.meshgrid(y, x, indexing="ij")
# Pre-compute angle (atan2) and radius — they don't change per frame
self._angle = np.arctan2(self._yy, self._xx) # -pi..pi
self._radius = np.sqrt(self._xx ** 2 + self._yy ** 2)
self._radius = np.sqrt(self._xx**2 + self._yy**2)
def initialize(self) -> None:
self._initialized = True
@@ -86,21 +88,37 @@ class DemoCaptureStream(CaptureStream):
q = val * (1.0 - frac)
t_ch = val * frac # "t" channel in HSV conversion
r = np.where(sector == 0, val,
np.where(sector == 1, q,
np.where(sector == 2, 0,
np.where(sector == 3, 0,
np.where(sector == 4, t_ch, val)))))
g = np.where(sector == 0, t_ch,
np.where(sector == 1, val,
np.where(sector == 2, val,
np.where(sector == 3, q,
np.where(sector == 4, 0, 0)))))
b = np.where(sector == 0, 0,
np.where(sector == 1, 0,
np.where(sector == 2, t_ch,
np.where(sector == 3, val,
np.where(sector == 4, val, q)))))
r = np.where(
sector == 0,
val,
np.where(
sector == 1,
q,
np.where(
sector == 2, 0, np.where(sector == 3, 0, np.where(sector == 4, t_ch, val))
),
),
)
g = np.where(
sector == 0,
t_ch,
np.where(
sector == 1,
val,
np.where(sector == 2, val, np.where(sector == 3, q, np.where(sector == 4, 0, 0))),
),
)
b = np.where(
sector == 0,
0,
np.where(
sector == 1,
0,
np.where(
sector == 2, t_ch, np.where(sector == 3, val, np.where(sector == 4, val, q))
),
),
)
small_u8 = (np.stack([r, g, b], axis=-1) * 255.0).astype(np.uint8)
@@ -108,7 +126,8 @@ class DemoCaptureStream(CaptureStream):
if self._RENDER_SCALE > 1:
image = np.repeat(
np.repeat(small_u8, self._RENDER_SCALE, axis=0),
self._RENDER_SCALE, axis=1,
self._RENDER_SCALE,
axis=1,
)[: self._height, : self._width]
else:
image = small_u8
@@ -129,36 +148,40 @@ class DemoCaptureEngine(CaptureEngine):
"""
ENGINE_TYPE = "demo"
ENGINE_PRIORITY = 1000 # Highest priority in demo mode
ENGINE_PRIORITY = 0 # Lowest — never outranks real engines
@classmethod
def is_available(cls) -> bool:
return is_demo_mode()
return True
@classmethod
def get_default_config(cls) -> Dict[str, Any]:
return {}
return {"simulation_type": "radial_gradient"}
@classmethod
def get_available_displays(cls) -> List[DisplayInfo]:
displays = []
for idx, (name, width, height, x, y, primary) in enumerate(_VIRTUAL_DISPLAYS):
displays.append(DisplayInfo(
index=idx,
name=name,
width=width,
height=height,
x=x,
y=y,
is_primary=primary,
refresh_rate=60,
))
displays.append(
DisplayInfo(
index=idx,
name=name,
width=width,
height=height,
x=x,
y=y,
is_primary=primary,
refresh_rate=60,
)
)
logger.debug(f"Demo engine: {len(displays)} virtual display(s)")
return displays
@classmethod
def create_stream(
cls, display_index: int, config: Dict[str, Any],
cls,
display_index: int,
config: Dict[str, Any],
) -> DemoCaptureStream:
if display_index < 0 or display_index >= len(_VIRTUAL_DISPLAYS):
raise ValueError(
@@ -0,0 +1,39 @@
"""Game integration core — event bus, adapters, and event vocabulary.
Re-exports the main public API for convenience:
from wled_controller.core.game_integration import GameEvent, GameEventBus, ...
"""
from wled_controller.core.game_integration.adapter_registry import AdapterRegistry
from wled_controller.core.game_integration.base_adapter import GameAdapter
from wled_controller.core.game_integration.event_bus import GameEventBus
from wled_controller.core.game_integration.events import (
EventCategory,
EventTypeMetadata,
GameEvent,
ValueType,
get_event_metadata,
get_event_vocabulary,
is_known_event_type,
)
from wled_controller.core.game_integration.mapping_adapter import (
MappingAdapter,
load_adapter_from_yaml,
validate_adapter_yaml,
)
__all__ = [
"AdapterRegistry",
"EventCategory",
"EventTypeMetadata",
"GameAdapter",
"GameEvent",
"GameEventBus",
"MappingAdapter",
"ValueType",
"get_event_metadata",
"get_event_vocabulary",
"is_known_event_type",
"load_adapter_from_yaml",
"validate_adapter_yaml",
]
@@ -0,0 +1,98 @@
"""Registry for game integration adapters.
Follows the EngineRegistry pattern from capture_engines/factory.py:
class-level dict, register/get/list methods, clear for testing.
"""
from typing import Type
from wled_controller.core.game_integration.base_adapter import GameAdapter
from wled_controller.utils import get_logger
logger = get_logger(__name__)
class AdapterRegistry:
"""Registry of available game adapters.
Maintains a mapping of adapter_type strings to GameAdapter subclasses.
All methods are classmethods operating on shared class-level state.
"""
_adapters: dict[str, Type[GameAdapter]] = {}
@classmethod
def register(cls, adapter_class: Type[GameAdapter]) -> None:
"""Register a game adapter class.
Args:
adapter_class: Must be a subclass of GameAdapter.
Raises:
ValueError: If not a GameAdapter subclass or has reserved type.
"""
if not (isinstance(adapter_class, type) and issubclass(adapter_class, GameAdapter)):
raise ValueError(f"{adapter_class} must be a subclass of GameAdapter")
adapter_type = adapter_class.ADAPTER_TYPE
if adapter_type == "base":
raise ValueError("Cannot register adapter with reserved type 'base'")
if adapter_type in cls._adapters:
logger.warning(f"Adapter '{adapter_type}' already registered, overwriting")
cls._adapters[adapter_type] = adapter_class
logger.info(f"Registered game adapter: {adapter_type}")
@classmethod
def get_adapter(cls, adapter_type: str) -> Type[GameAdapter]:
"""Look up an adapter class by type.
Args:
adapter_type: Adapter type identifier.
Returns:
The adapter class.
Raises:
ValueError: If the adapter type is not registered.
"""
if adapter_type not in cls._adapters:
available = ", ".join(cls._adapters.keys()) or "none"
raise ValueError(f"Unknown adapter type: '{adapter_type}'. Available: {available}")
return cls._adapters[adapter_type]
@classmethod
def get_all_adapters(cls) -> dict[str, Type[GameAdapter]]:
"""Return all registered adapters (copy).
Returns:
Dict mapping adapter_type to adapter class.
"""
return dict(cls._adapters)
@classmethod
def get_available_adapters(cls) -> list[dict]:
"""Return metadata for all registered adapters.
Returns:
List of dicts with adapter_type, display_name, game_name,
supported_events for each registered adapter.
"""
result = []
for adapter_type, adapter_class in cls._adapters.items():
result.append(
{
"adapter_type": adapter_type,
"display_name": adapter_class.DISPLAY_NAME,
"game_name": adapter_class.GAME_NAME,
"supported_events": list(adapter_class.SUPPORTED_EVENTS),
}
)
return result
@classmethod
def clear_registry(cls) -> None:
"""Clear all registered adapters (for testing)."""
cls._adapters.clear()
logger.debug("Cleared adapter registry")
@@ -0,0 +1,25 @@
"""Built-in game adapters package.
Registers all built-in adapters with the AdapterRegistry on import.
"""
from wled_controller.core.game_integration.adapter_registry import AdapterRegistry
from wled_controller.core.game_integration.adapters.cs2_adapter import CS2Adapter
from wled_controller.core.game_integration.adapters.dota2_adapter import Dota2Adapter
from wled_controller.core.game_integration.adapters.generic_webhook_adapter import (
GenericWebhookAdapter,
)
from wled_controller.core.game_integration.adapters.lol_adapter import LoLAdapter
# Register all built-in adapters
AdapterRegistry.register(CS2Adapter)
AdapterRegistry.register(Dota2Adapter)
AdapterRegistry.register(LoLAdapter)
AdapterRegistry.register(GenericWebhookAdapter)
__all__ = [
"CS2Adapter",
"Dota2Adapter",
"GenericWebhookAdapter",
"LoLAdapter",
]
@@ -0,0 +1,408 @@
"""CS2 (Counter-Strike 2) Game State Integration adapter.
Parses CS2 GSI JSON payloads into standardized GameEvents. CS2 sends
the full game state on each update — this adapter extracts player state,
round info, and uses diff-based detection for kills/deaths.
Ref: https://developer.valvesoftware.com/wiki/Counter-Strike:_Global_Offensive_Game_State_Integration
"""
import secrets
import time
from typing import Any, ClassVar
from wled_controller.core.game_integration.base_adapter import GameAdapter
from wled_controller.core.game_integration.events import GameEvent
from wled_controller.utils import get_logger
logger = get_logger(__name__)
class CS2Adapter(GameAdapter):
"""Adapter for Counter-Strike 2 Game State Integration."""
ADAPTER_TYPE: ClassVar[str] = "cs2"
DISPLAY_NAME: ClassVar[str] = "Counter-Strike 2 GSI"
GAME_NAME: ClassVar[str] = "Counter-Strike 2"
SUPPORTED_EVENTS: ClassVar[list[str]] = [
"health",
"armor",
"ammo",
"gold", # money mapped to gold
"kill",
"death",
"round_start",
"round_end",
"objective_captured", # bomb_planted
"objective_lost", # bomb_defused (from attacker perspective)
"blinded", # flashbang
"team_a", # CT
"team_b", # T
]
@classmethod
def parse_payload(
cls,
payload: dict[str, Any],
adapter_config: dict[str, Any],
prev_state: dict[str, Any],
) -> tuple[list[GameEvent], dict[str, Any]]:
"""Parse a CS2 GSI payload into standardized events."""
events: list[GameEvent] = []
new_state = dict(prev_state)
adapter_id = adapter_config.get("adapter_id", "cs2")
now = time.monotonic()
player = payload.get("player", {})
player_state = player.get("state", {})
match_stats = player.get("match_stats", {})
round_info = payload.get("round", {})
# ── Continuous: health ──
health = player_state.get("health")
if health is not None:
events.append(
GameEvent(
adapter_id=adapter_id,
event_type="health",
value=max(0.0, min(1.0, float(health) / 100.0)),
raw_data={"health": health},
timestamp=now,
)
)
# ── Continuous: armor ──
armor = player_state.get("armor")
if armor is not None:
events.append(
GameEvent(
adapter_id=adapter_id,
event_type="armor",
value=max(0.0, min(1.0, float(armor) / 100.0)),
raw_data={"armor": armor},
timestamp=now,
)
)
# ── Continuous: money (mapped to gold) ──
money = player_state.get("money")
if money is not None:
events.append(
GameEvent(
adapter_id=adapter_id,
event_type="gold",
value=max(0.0, min(1.0, float(money) / 16000.0)),
raw_data={"money": money},
timestamp=now,
)
)
# ── Continuous: ammo ──
# CS2 reports clip ammo in weapon.ammo_clip and reserve in ammo_reserve
weapons = player.get("weapons", {})
active_weapon = _get_active_weapon(weapons)
if active_weapon:
clip = active_weapon.get("ammo_clip")
clip_max = active_weapon.get("ammo_clip_max")
if clip is not None and clip_max and clip_max > 0:
events.append(
GameEvent(
adapter_id=adapter_id,
event_type="ammo",
value=max(0.0, min(1.0, float(clip) / float(clip_max))),
raw_data={"ammo_clip": clip, "ammo_clip_max": clip_max},
timestamp=now,
)
)
# ── Trigger: kills (diff-based) ──
kills = match_stats.get("kills")
if kills is not None:
prev_kills = prev_state.get("kills")
new_state["kills"] = kills
if prev_kills is not None and kills > prev_kills:
for _ in range(kills - prev_kills):
events.append(
GameEvent(
adapter_id=adapter_id,
event_type="kill",
value=1.0,
raw_data={"kills": kills, "prev_kills": prev_kills},
timestamp=now,
)
)
# ── Trigger: deaths (diff-based) ──
deaths = match_stats.get("deaths")
if deaths is not None:
prev_deaths = prev_state.get("deaths")
new_state["deaths"] = deaths
if prev_deaths is not None and deaths > prev_deaths:
events.append(
GameEvent(
adapter_id=adapter_id,
event_type="death",
value=1.0,
raw_data={"deaths": deaths, "prev_deaths": prev_deaths},
timestamp=now,
)
)
# ── Trigger: round phase changes ──
round_phase = round_info.get("phase")
prev_round_phase = prev_state.get("round_phase")
new_state["round_phase"] = round_phase
if round_phase and round_phase != prev_round_phase:
if round_phase == "live":
events.append(
GameEvent(
adapter_id=adapter_id,
event_type="round_start",
value=1.0,
raw_data={"round_phase": round_phase},
timestamp=now,
)
)
elif round_phase == "over":
events.append(
GameEvent(
adapter_id=adapter_id,
event_type="round_end",
value=1.0,
raw_data={"round_phase": round_phase},
timestamp=now,
)
)
# ── Trigger: bomb state ──
bomb = round_info.get("bomb")
prev_bomb = prev_state.get("bomb")
new_state["bomb"] = bomb
if bomb and bomb != prev_bomb:
if bomb == "planted":
events.append(
GameEvent(
adapter_id=adapter_id,
event_type="objective_captured",
value=1.0,
raw_data={"bomb": bomb},
timestamp=now,
)
)
elif bomb == "defused":
events.append(
GameEvent(
adapter_id=adapter_id,
event_type="objective_lost",
value=1.0,
raw_data={"bomb": bomb},
timestamp=now,
)
)
# ── Trigger: flashbang ──
flashed = player_state.get("flashed")
if flashed is not None and flashed > 0:
prev_flashed = prev_state.get("flashed", 0)
if prev_flashed == 0:
events.append(
GameEvent(
adapter_id=adapter_id,
event_type="blinded",
value=max(0.0, min(1.0, float(flashed) / 255.0)),
raw_data={"flashed": flashed},
timestamp=now,
)
)
new_state["flashed"] = flashed if flashed is not None else 0
# ── Team affiliation ──
team = player.get("team")
if team:
new_state["team"] = team
if team == "CT":
events.append(
GameEvent(
adapter_id=adapter_id,
event_type="team_a",
value=1.0,
raw_data={"team": team},
timestamp=now,
)
)
elif team == "T":
events.append(
GameEvent(
adapter_id=adapter_id,
event_type="team_b",
value=1.0,
raw_data={"team": team},
timestamp=now,
)
)
return events, new_state
@classmethod
def validate_auth(
cls,
headers: dict[str, str],
payload: dict[str, Any],
adapter_config: dict[str, Any],
) -> bool:
"""Validate CS2 GSI auth token from payload["auth"]["token"]."""
expected_token = adapter_config.get("auth_token")
if not expected_token:
# No token configured — accept all
return True
auth_section = payload.get("auth", {})
actual_token = auth_section.get("token", "")
return bool(actual_token and actual_token == expected_token)
@classmethod
def get_config_schema(cls) -> dict[str, Any]:
"""Return CS2 adapter config schema."""
return {
"type": "object",
"properties": {
"auth_token": {
"type": "string",
"title": "Auth Token",
"description": (
"The token string you set in your CS2 GSI config file "
"(gamestate_integration_*.cfg). Must match exactly."
),
},
},
}
@classmethod
def get_setup_instructions(cls) -> str:
"""Return CS2 GSI setup instructions."""
return (
"## CS2 Game State Integration Setup\n\n"
"1. Navigate to your CS2 config folder:\n"
" `Steam/steamapps/common/Counter-Strike Global Offensive/game/csgo/cfg/`\n\n"
"2. Create a file named `gamestate_integration_wled.cfg` with:\n"
" ```\n"
' "WLED Screen Controller"\n'
" {\n"
' "uri" "http://<YOUR_IP>:8080/api/v1/game-integrations/<ID>/event"\n'
' "timeout" "5.0"\n'
' "buffer" "0.1"\n'
' "throttle" "0.1"\n'
' "heartbeat" "30.0"\n'
' "auth"\n'
" {\n"
' "token" "<YOUR_TOKEN>"\n'
" }\n"
' "data"\n'
" {\n"
' "player_id" "1"\n'
' "player_state" "1"\n'
' "player_weapons" "1"\n'
' "player_match_stats" "1"\n'
' "round" "1"\n'
' "map" "1"\n'
" }\n"
" }\n"
" ```\n\n"
"3. Set the same token in the adapter config above.\n\n"
"4. Launch CS2 — events will start flowing automatically.\n"
)
@classmethod
def supports_auto_setup(cls) -> bool:
"""CS2 supports automatic GSI config file generation."""
return True
@classmethod
def auto_setup(
cls,
integration_id: str,
adapter_config: dict[str, Any],
server_url: str,
) -> dict[str, Any]:
"""Write a CS2 GSI config file automatically.
Generates an auth token if none is configured. Writes the config
to the CS2 cfg directory.
"""
from wled_controller.core.game_integration.steam_finder import find_game_cfg_path
cfg_path = find_game_cfg_path("cs2")
if not cfg_path:
return {
"success": False,
"file_path": "",
"message": "CS2 installation not found. Is Steam/CS2 installed?",
"token_generated": False,
"adapter_config": adapter_config,
}
# Generate auth token if not set
token_generated = False
config = dict(adapter_config)
auth_token = config.get("auth_token", "")
if not auth_token:
auth_token = secrets.token_hex(16)
config["auth_token"] = auth_token
token_generated = True
uri = f"{server_url}/api/v1/game-integrations/{integration_id}/event"
cfg_content = (
'"WLED Screen Controller"\n'
"{\n"
f' "uri" "{uri}"\n'
' "timeout" "5.0"\n'
' "buffer" "0.1"\n'
' "throttle" "0.1"\n'
' "heartbeat" "30.0"\n'
' "auth"\n'
" {\n"
f' "token" "{auth_token}"\n'
" }\n"
' "data"\n'
" {\n"
' "player_id" "1"\n'
' "player_state" "1"\n'
' "player_weapons" "1"\n'
' "player_match_stats" "1"\n'
' "round" "1"\n'
' "map" "1"\n'
" }\n"
"}\n"
)
file_path = cfg_path / "gamestate_integration_wled.cfg"
try:
file_path.write_text(cfg_content, encoding="utf-8")
logger.info("Wrote CS2 GSI config to %s", file_path)
except OSError as e:
return {
"success": False,
"file_path": str(file_path),
"message": f"Failed to write config file: {e}",
"token_generated": False,
"adapter_config": adapter_config,
}
return {
"success": True,
"file_path": str(file_path),
"message": "CS2 GSI config written successfully. Restart CS2 to apply.",
"token_generated": token_generated,
"adapter_config": config,
}
def _get_active_weapon(weapons: dict[str, Any]) -> dict[str, Any] | None:
"""Find the currently active weapon from the CS2 weapons dict."""
for weapon in weapons.values():
if isinstance(weapon, dict) and weapon.get("state") == "active":
return weapon
return None
@@ -0,0 +1,330 @@
"""Dota 2 Game State Integration adapter.
Parses Dota 2 GSI JSON payloads into standardized GameEvents. Dota 2's GSI
format is similar to CS2 but with different payload structure focused on
hero state, match info, and gold.
Ref: https://developer.valvesoftware.com/wiki/Dota_2_Game_State_Integration
"""
import secrets
import time
from typing import Any, ClassVar
from wled_controller.core.game_integration.base_adapter import GameAdapter
from wled_controller.core.game_integration.events import GameEvent
from wled_controller.utils import get_logger
logger = get_logger(__name__)
class Dota2Adapter(GameAdapter):
"""Adapter for Dota 2 Game State Integration."""
ADAPTER_TYPE: ClassVar[str] = "dota2"
DISPLAY_NAME: ClassVar[str] = "Dota 2 GSI"
GAME_NAME: ClassVar[str] = "Dota 2"
SUPPORTED_EVENTS: ClassVar[list[str]] = [
"health",
"mana",
"kill",
"death",
"match_start",
"match_end",
"gold",
]
@classmethod
def parse_payload(
cls,
payload: dict[str, Any],
adapter_config: dict[str, Any],
prev_state: dict[str, Any],
) -> tuple[list[GameEvent], dict[str, Any]]:
"""Parse a Dota 2 GSI payload into standardized events."""
events: list[GameEvent] = []
new_state = dict(prev_state)
adapter_id = adapter_config.get("adapter_id", "dota2")
now = time.monotonic()
hero = payload.get("hero", {})
player = payload.get("player", {})
game_map = payload.get("map", {})
# ── Continuous: health ──
hp = hero.get("health")
max_hp = hero.get("max_health")
if hp is not None and max_hp and max_hp > 0:
events.append(
GameEvent(
adapter_id=adapter_id,
event_type="health",
value=max(0.0, min(1.0, float(hp) / float(max_hp))),
raw_data={"health": hp, "max_health": max_hp},
timestamp=now,
)
)
# ── Continuous: mana ──
mp = hero.get("mana")
max_mp = hero.get("max_mana")
if mp is not None and max_mp and max_mp > 0:
events.append(
GameEvent(
adapter_id=adapter_id,
event_type="mana",
value=max(0.0, min(1.0, float(mp) / float(max_mp))),
raw_data={"mana": mp, "max_mana": max_mp},
timestamp=now,
)
)
# ── Continuous: gold ──
gold = player.get("gold")
if gold is not None:
# Normalize to 0-1 with a reasonable max (99999 net worth)
max_gold = float(adapter_config.get("max_gold", 99999))
events.append(
GameEvent(
adapter_id=adapter_id,
event_type="gold",
value=max(0.0, min(1.0, float(gold) / max_gold)),
raw_data={"gold": gold},
timestamp=now,
)
)
# ── Trigger: kills (diff-based) ──
kills = player.get("kills")
if kills is not None:
prev_kills = prev_state.get("kills")
new_state["kills"] = kills
if prev_kills is not None and kills > prev_kills:
for _ in range(kills - prev_kills):
events.append(
GameEvent(
adapter_id=adapter_id,
event_type="kill",
value=1.0,
raw_data={"kills": kills, "prev_kills": prev_kills},
timestamp=now,
)
)
# ── Trigger: deaths (diff-based) ──
deaths = player.get("deaths")
if deaths is not None:
prev_deaths = prev_state.get("deaths")
new_state["deaths"] = deaths
if prev_deaths is not None and deaths > prev_deaths:
events.append(
GameEvent(
adapter_id=adapter_id,
event_type="death",
value=1.0,
raw_data={"deaths": deaths, "prev_deaths": prev_deaths},
timestamp=now,
)
)
# ── Trigger: match phase ──
game_state = game_map.get("game_state")
prev_game_state = prev_state.get("game_state")
new_state["game_state"] = game_state
if game_state and game_state != prev_game_state:
# Dota 2 states: DOTA_GAMERULES_STATE_*
if game_state in (
"DOTA_GAMERULES_STATE_PRE_GAME",
"DOTA_GAMERULES_STATE_GAME_IN_PROGRESS",
):
events.append(
GameEvent(
adapter_id=adapter_id,
event_type="match_start",
value=1.0,
raw_data={"game_state": game_state},
timestamp=now,
)
)
elif game_state in (
"DOTA_GAMERULES_STATE_POST_GAME",
"DOTA_GAMERULES_STATE_DISCONNECT",
):
events.append(
GameEvent(
adapter_id=adapter_id,
event_type="match_end",
value=1.0,
raw_data={"game_state": game_state},
timestamp=now,
)
)
return events, new_state
@classmethod
def validate_auth(
cls,
headers: dict[str, str],
payload: dict[str, Any],
adapter_config: dict[str, Any],
) -> bool:
"""Validate Dota 2 GSI auth token from payload["auth"]["token"]."""
expected_token = adapter_config.get("auth_token")
if not expected_token:
return True
auth_section = payload.get("auth", {})
actual_token = auth_section.get("token", "")
return bool(actual_token and actual_token == expected_token)
@classmethod
def get_config_schema(cls) -> dict[str, Any]:
"""Return Dota 2 adapter config schema."""
return {
"type": "object",
"properties": {
"auth_token": {
"type": "string",
"title": "Auth Token",
"description": (
"The token string from your Dota 2 GSI config file. "
"Must match the token in gamestate_integration_*.cfg."
),
},
"max_gold": {
"type": "number",
"title": "Max Gold",
"description": "Maximum gold value for normalization (default: 99999).",
"default": 99999,
},
},
}
@classmethod
def get_setup_instructions(cls) -> str:
"""Return Dota 2 GSI setup instructions."""
return (
"## Dota 2 Game State Integration Setup\n\n"
"1. Navigate to your Dota 2 config folder:\n"
" `Steam/steamapps/common/dota 2 beta/game/dota/cfg/gamestate_integration/`\n\n"
"2. Create a file named `gamestate_integration_wled.cfg` with:\n"
" ```\n"
' "WLED Screen Controller"\n'
" {\n"
' "uri" "http://<YOUR_IP>:8080/api/v1/game-integrations/<ID>/event"\n'
' "timeout" "5.0"\n'
' "buffer" "0.1"\n'
' "throttle" "0.1"\n'
' "heartbeat" "30.0"\n'
' "auth"\n'
" {\n"
' "token" "<YOUR_TOKEN>"\n'
" }\n"
' "data"\n'
" {\n"
' "hero" "1"\n'
' "player" "1"\n'
' "map" "1"\n'
" }\n"
" }\n"
" ```\n\n"
"3. Set the same token in the adapter config above.\n\n"
"4. Launch Dota 2 and enter a match — events will start flowing.\n"
)
@classmethod
def supports_auto_setup(cls) -> bool:
"""Dota 2 supports automatic GSI config file generation."""
return True
@classmethod
def auto_setup(
cls,
integration_id: str,
adapter_config: dict[str, Any],
server_url: str,
) -> dict[str, Any]:
"""Write a Dota 2 GSI config file automatically.
Generates an auth token if none is configured. Writes the config
to the Dota 2 gamestate_integration subdirectory (created if needed).
"""
from wled_controller.core.game_integration.steam_finder import find_game_cfg_path
cfg_path = find_game_cfg_path("dota2")
if not cfg_path:
return {
"success": False,
"file_path": "",
"message": "Dota 2 installation not found. Is Steam/Dota 2 installed?",
"token_generated": False,
"adapter_config": adapter_config,
}
# Dota 2 GSI configs live in a gamestate_integration/ subdirectory
gsi_dir = cfg_path / "gamestate_integration"
try:
gsi_dir.mkdir(parents=False, exist_ok=True)
except OSError as e:
return {
"success": False,
"file_path": str(gsi_dir),
"message": f"Failed to create gamestate_integration directory: {e}",
"token_generated": False,
"adapter_config": adapter_config,
}
# Generate auth token if not set
token_generated = False
config = dict(adapter_config)
auth_token = config.get("auth_token", "")
if not auth_token:
auth_token = secrets.token_hex(16)
config["auth_token"] = auth_token
token_generated = True
uri = f"{server_url}/api/v1/game-integrations/{integration_id}/event"
cfg_content = (
'"WLED Screen Controller"\n'
"{\n"
f' "uri" "{uri}"\n'
' "timeout" "5.0"\n'
' "buffer" "0.1"\n'
' "throttle" "0.1"\n'
' "heartbeat" "30.0"\n'
' "auth"\n'
" {\n"
f' "token" "{auth_token}"\n'
" }\n"
' "data"\n'
" {\n"
' "hero" "1"\n'
' "player" "1"\n'
' "map" "1"\n'
" }\n"
"}\n"
)
file_path = gsi_dir / "gamestate_integration_wled.cfg"
try:
file_path.write_text(cfg_content, encoding="utf-8")
logger.info("Wrote Dota 2 GSI config to %s", file_path)
except OSError as e:
return {
"success": False,
"file_path": str(file_path),
"message": f"Failed to write config file: {e}",
"token_generated": False,
"adapter_config": adapter_config,
}
return {
"success": True,
"file_path": str(file_path),
"message": "Dota 2 GSI config written successfully. Restart Dota 2 to apply.",
"token_generated": token_generated,
"adapter_config": config,
}
@@ -0,0 +1,172 @@
"""Generic webhook adapter with user-defined JSON path mappings.
Allows users to define custom JSON path mappings via the adapter_config
rather than a YAML file. Delegates all parsing logic to MappingAdapter.
"""
from typing import Any, ClassVar
from wled_controller.core.game_integration.base_adapter import GameAdapter
from wled_controller.core.game_integration.events import GameEvent
from wled_controller.core.game_integration.mapping_adapter import MappingAdapter
from wled_controller.utils import get_logger
logger = get_logger(__name__)
class GenericWebhookAdapter(GameAdapter):
"""Generic webhook adapter with user-defined JSON path mappings.
Users configure mappings in the adapter_config field of their
game integration config. The mappings follow the same format as
MappingAdapter YAML files.
"""
ADAPTER_TYPE: ClassVar[str] = "generic_webhook"
DISPLAY_NAME: ClassVar[str] = "Generic Webhook"
GAME_NAME: ClassVar[str] = "Any Game"
SUPPORTED_EVENTS: ClassVar[list[str]] = [] # Dynamic based on mappings
@classmethod
def parse_payload(
cls,
payload: dict[str, Any],
adapter_config: dict[str, Any],
prev_state: dict[str, Any],
) -> tuple[list[GameEvent], dict[str, Any]]:
"""Parse a webhook payload using user-defined mappings.
Delegates to a MappingAdapter instance built from adapter_config["mappings"].
"""
mappings = adapter_config.get("mappings", [])
if not mappings:
return [], dict(prev_state)
# Build a transient MappingAdapter from the config
mapping_adapter = _build_mapping_adapter(adapter_config)
return mapping_adapter.parse_payload(payload, adapter_config, prev_state)
@classmethod
def validate_auth(
cls,
headers: dict[str, str],
payload: dict[str, Any],
adapter_config: dict[str, Any],
) -> bool:
"""Validate auth using a configurable header token."""
expected_token = adapter_config.get("auth_token")
if not expected_token:
# No auth configured
return True
auth_header = adapter_config.get("auth_header", "Authorization")
actual_value = headers.get(auth_header, "")
# Support "Bearer <token>" format
if actual_value.startswith("Bearer "):
actual_value = actual_value[7:]
return bool(actual_value and actual_value == expected_token)
@classmethod
def get_config_schema(cls) -> dict[str, Any]:
"""Return generic webhook config schema."""
return {
"type": "object",
"properties": {
"auth_token": {
"type": "string",
"title": "Auth Token",
"description": "Optional token for authenticating incoming webhooks.",
},
"auth_header": {
"type": "string",
"title": "Auth Header",
"description": (
"HTTP header to check for auth token "
"(default: Authorization). Supports Bearer prefix."
),
"default": "Authorization",
},
"mappings": {
"type": "array",
"title": "Event Mappings",
"description": "List of JSON path to event type mappings.",
"items": {
"type": "object",
"properties": {
"source_path": {
"type": "string",
"description": "Dot-notation path in JSON payload (e.g. player.health)",
},
"event": {
"type": "string",
"description": "Standard event type (e.g. health, kill, death)",
},
"min": {
"type": "number",
"description": "Minimum raw value for normalization (default: 0)",
"default": 0,
},
"max": {
"type": "number",
"description": "Maximum raw value for normalization (default: 100)",
"default": 100,
},
"trigger": {
"type": "string",
"description": "Trigger mode: on_change, on_increase, on_decrease, on_value",
"default": "on_change",
"enum": ["on_change", "on_increase", "on_decrease", "on_value"],
},
},
"required": ["source_path", "event"],
},
},
},
}
@classmethod
def get_setup_instructions(cls) -> str:
"""Return generic webhook setup instructions."""
return (
"## Generic Webhook Setup\n\n"
"Use this adapter to connect any game or application that can send "
"HTTP POST requests with JSON payloads.\n\n"
"**Steps:**\n"
"1. Configure your event mappings above — map JSON paths to standard events\n"
"2. Set an auth token (optional but recommended)\n"
"3. Point your game/application to:\n"
" `POST http://<YOUR_IP>:8080/api/v1/game-integrations/<ID>/event`\n\n"
"**Mapping example:**\n"
"- Source path: `player.stats.health` → Event: `health` (min: 0, max: 100)\n"
"- Source path: `events.kill_count` → Event: `kill` (trigger: on_increase)\n\n"
"**Auth:**\n"
"- Set `Authorization: Bearer <token>` header in your webhook sender\n"
"- Or configure a custom auth header name in the adapter config\n"
)
def _build_mapping_adapter(adapter_config: dict[str, Any]) -> MappingAdapter:
"""Build a MappingAdapter instance from adapter_config."""
mappings = adapter_config.get("mappings", [])
name = adapter_config.get("adapter_id", "generic_webhook")
game = adapter_config.get("game_name", "Custom Game")
auth: dict[str, Any] = {}
if adapter_config.get("auth_token"):
auth = {
"type": "header",
"header": adapter_config.get("auth_header", "Authorization"),
}
return MappingAdapter(
{
"name": name,
"game": game,
"protocol": "webhook",
"mappings": mappings,
"auth": auth,
}
)
@@ -0,0 +1,285 @@
"""League of Legends Live Client Data API adapter.
Poll-based adapter that fetches game data from the LoL client's local
HTTP API at https://127.0.0.1:2999/liveclientdata/allgamedata.
Unlike GSI adapters (CS2, Dota 2), this adapter manages its own polling
thread. The thread is started/stopped via start_polling()/stop_polling().
Ref: https://developer.riotgames.com/docs/lol#game-client-api
"""
import threading
import time
from typing import Any, ClassVar
from wled_controller.core.game_integration.base_adapter import GameAdapter
from wled_controller.core.game_integration.events import GameEvent
from wled_controller.utils import get_logger
logger = get_logger(__name__)
# LoL Live Client Data API base URL (local, self-signed SSL)
LOL_API_BASE = "https://127.0.0.1:2999/liveclientdata"
class LoLAdapter(GameAdapter):
"""Adapter for League of Legends Live Client Data API."""
ADAPTER_TYPE: ClassVar[str] = "lol"
DISPLAY_NAME: ClassVar[str] = "League of Legends"
GAME_NAME: ClassVar[str] = "League of Legends"
SUPPORTED_EVENTS: ClassVar[list[str]] = [
"health",
"mana",
"gold",
"death",
"objective_progress", # respawn mapped to objective_progress
"speed", # player level mapped to speed (continuous 0-18)
]
@classmethod
def parse_payload(
cls,
payload: dict[str, Any],
adapter_config: dict[str, Any],
prev_state: dict[str, Any],
) -> tuple[list[GameEvent], dict[str, Any]]:
"""Parse a LoL Live Client Data payload into standardized events.
The payload is the full allgamedata response containing
activePlayer, allPlayers, gameData, and events sections.
"""
events: list[GameEvent] = []
new_state = dict(prev_state)
adapter_id = adapter_config.get("adapter_id", "lol")
now = time.monotonic()
active_player = payload.get("activePlayer", {})
champion_stats = active_player.get("championStats", {})
summoner_name = active_player.get("summonerName", "")
# ── Continuous: health ──
hp = champion_stats.get("currentHealth")
max_hp = champion_stats.get("maxHealth")
if hp is not None and max_hp and max_hp > 0:
events.append(
GameEvent(
adapter_id=adapter_id,
event_type="health",
value=max(0.0, min(1.0, float(hp) / float(max_hp))),
raw_data={"health": hp, "max_health": max_hp},
timestamp=now,
)
)
# ── Continuous: mana (resourceValue) ──
resource_val = champion_stats.get("resourceValue")
resource_max = champion_stats.get("resourceMax")
if resource_val is not None and resource_max and resource_max > 0:
events.append(
GameEvent(
adapter_id=adapter_id,
event_type="mana",
value=max(0.0, min(1.0, float(resource_val) / float(resource_max))),
raw_data={"mana": resource_val, "max_mana": resource_max},
timestamp=now,
)
)
# ── Continuous: level (mapped to speed, normalized 0-18) ──
level = active_player.get("level")
if level is not None:
events.append(
GameEvent(
adapter_id=adapter_id,
event_type="speed",
value=max(0.0, min(1.0, float(level) / 18.0)),
raw_data={"level": level},
timestamp=now,
)
)
# ── Continuous: gold ──
# Gold is available per player in allPlayers array
all_players = payload.get("allPlayers", [])
player_data = _find_player_by_name(all_players, summoner_name)
if player_data:
gold = player_data.get("currentGold")
if gold is not None:
max_gold = float(adapter_config.get("max_gold", 30000))
events.append(
GameEvent(
adapter_id=adapter_id,
event_type="gold",
value=max(0.0, min(1.0, float(gold) / max_gold)),
raw_data={"gold": gold},
timestamp=now,
)
)
# ── Trigger: death detection ──
# If health drops to 0, player is dead
if hp is not None and float(hp) <= 0:
was_alive = prev_state.get("alive", True)
if was_alive:
events.append(
GameEvent(
adapter_id=adapter_id,
event_type="death",
value=1.0,
raw_data={"health": hp},
timestamp=now,
)
)
new_state["alive"] = False
elif hp is not None and float(hp) > 0:
was_alive = prev_state.get("alive", True)
if not was_alive:
# Respawn detected
events.append(
GameEvent(
adapter_id=adapter_id,
event_type="objective_progress",
value=1.0,
raw_data={"respawn": True, "health": hp},
timestamp=now,
)
)
new_state["alive"] = True
return events, new_state
@classmethod
def validate_auth(
cls,
headers: dict[str, str],
payload: dict[str, Any],
adapter_config: dict[str, Any],
) -> bool:
"""LoL Live Client API is local-only — no auth needed."""
return True
@classmethod
def get_config_schema(cls) -> dict[str, Any]:
"""Return LoL adapter config schema."""
return {
"type": "object",
"properties": {
"poll_interval_ms": {
"type": "integer",
"title": "Poll Interval (ms)",
"description": "How often to poll the LoL client API (default: 500ms).",
"default": 500,
"minimum": 100,
"maximum": 5000,
},
"max_gold": {
"type": "number",
"title": "Max Gold",
"description": "Maximum gold for normalization (default: 30000).",
"default": 30000,
},
},
}
@classmethod
def get_setup_instructions(cls) -> str:
"""Return LoL setup instructions."""
return (
"## League of Legends Live Client Data Setup\n\n"
"The LoL Live Client Data API runs automatically when you're in a game.\n\n"
"**Requirements:**\n"
"- WLED Screen Controller must run on the same machine as LoL\n"
"- The LoL client exposes data at `https://127.0.0.1:2999`\n"
"- No configuration needed in the LoL client\n\n"
"**How it works:**\n"
"- The adapter polls the local API at the configured interval\n"
"- Data is only available while you're in an active game\n"
"- The API uses a self-signed SSL certificate (handled automatically)\n\n"
"**Note:** This adapter uses polling mode. Enable the integration, "
"then start a game — events will appear automatically.\n"
)
class LoLPoller:
"""Polling thread manager for the LoL Live Client Data API.
Creates a daemon thread that periodically fetches game data from the
local LoL client API and passes it through the adapter.
"""
def __init__(
self,
adapter_config: dict[str, Any],
callback: Any, # Callable[[dict], None]
) -> None:
self._adapter_config = adapter_config
self._callback = callback
self._stop_event = threading.Event()
self._thread: threading.Thread | None = None
self._poll_interval = adapter_config.get("poll_interval_ms", 500) / 1000.0
def start(self) -> None:
"""Start the polling thread."""
if self._thread and self._thread.is_alive():
logger.warning("LoL poller already running")
return
self._stop_event.clear()
self._thread = threading.Thread(
target=self._poll_loop,
name="lol-poller",
daemon=True,
)
self._thread.start()
logger.info("LoL poller started (interval: %.1fs)", self._poll_interval)
def stop(self) -> None:
"""Stop the polling thread."""
self._stop_event.set()
if self._thread:
self._thread.join(timeout=5.0)
self._thread = None
logger.info("LoL poller stopped")
@property
def is_running(self) -> bool:
"""Check if the polling thread is alive."""
return self._thread is not None and self._thread.is_alive()
def _poll_loop(self) -> None:
"""Main polling loop — runs in a separate daemon thread."""
import urllib.request
import ssl
import json
# LoL uses self-signed cert — skip verification
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
url = f"{LOL_API_BASE}/allgamedata"
while not self._stop_event.is_set():
try:
req = urllib.request.Request(url)
with urllib.request.urlopen(req, timeout=2, context=ctx) as resp:
data = json.loads(resp.read().decode("utf-8"))
self._callback(data)
except Exception:
# Game not running or API not available — silently retry
pass
self._stop_event.wait(self._poll_interval)
def _find_player_by_name(
all_players: list[dict[str, Any]],
summoner_name: str,
) -> dict[str, Any] | None:
"""Find the active player's data in the allPlayers array."""
for player in all_players:
if player.get("summonerName") == summoner_name:
return player
return None
@@ -0,0 +1,109 @@
"""Abstract base class for game adapters.
Every game adapter (built-in, community YAML, or generic webhook) implements
this interface. The adapter is responsible for:
- Parsing a raw JSON payload into standardized GameEvent instances
- Validating authentication (webhook secret, etc.)
- Describing its configuration schema for UI auto-generation
- Providing setup instructions for the game
"""
from abc import ABC, abstractmethod
from typing import Any, ClassVar
from wled_controller.core.game_integration.events import GameEvent
class GameAdapter(ABC):
"""Base class for all game integration adapters."""
ADAPTER_TYPE: ClassVar[str] = "base"
DISPLAY_NAME: ClassVar[str] = "Base Adapter"
GAME_NAME: ClassVar[str] = "Unknown"
SUPPORTED_EVENTS: ClassVar[list[str]] = []
@classmethod
@abstractmethod
def parse_payload(
cls,
payload: dict[str, Any],
adapter_config: dict[str, Any],
prev_state: dict[str, Any],
) -> tuple[list[GameEvent], dict[str, Any]]:
"""Parse a game payload into standardized events.
Args:
payload: Raw JSON payload from the game.
adapter_config: Adapter-specific configuration (secrets, mappings).
prev_state: Previous adapter state for diff-based trigger detection.
Returns:
Tuple of (list of GameEvent instances, updated state dict).
"""
@classmethod
@abstractmethod
def validate_auth(
cls,
headers: dict[str, str],
payload: dict[str, Any],
adapter_config: dict[str, Any],
) -> bool:
"""Validate that the incoming request is authentic.
Args:
headers: HTTP request headers.
payload: Raw JSON payload.
adapter_config: Adapter-specific configuration (may contain secrets).
Returns:
True if the request is authenticated, False otherwise.
"""
@classmethod
def get_config_schema(cls) -> dict[str, Any]:
"""Return a JSON Schema describing adapter-specific config fields.
Override in subclasses to expose configuration options in the UI.
Default implementation returns an empty schema.
"""
return {"type": "object", "properties": {}}
@classmethod
def get_setup_instructions(cls) -> str:
"""Return Markdown setup instructions for this game adapter.
Override in subclasses to provide game-specific setup guidance.
"""
return f"No setup instructions available for {cls.DISPLAY_NAME}."
@classmethod
def supports_auto_setup(cls) -> bool:
"""Whether this adapter supports automatic GSI config file setup.
Override in subclasses that can write game config files.
"""
return False
@classmethod
def auto_setup(
cls,
integration_id: str,
adapter_config: dict[str, Any],
server_url: str,
) -> dict[str, Any]:
"""Automatically write game config files for this adapter.
Args:
integration_id: The integration ID (used in the callback URL).
adapter_config: Current adapter configuration (may be updated).
server_url: Base URL of the WLED controller server.
Returns:
Dict with keys: success (bool), file_path (str), message (str),
token_generated (bool), adapter_config (dict, possibly updated).
Raises:
NotImplementedError: If auto-setup is not supported.
"""
raise NotImplementedError(f"{cls.DISPLAY_NAME} does not support auto setup.")
@@ -0,0 +1,140 @@
"""Community adapter loader — scan data/game_adapters/ for YAML adapter files.
Loads all .yaml/.yml files from the community adapter directory and makes
them available for selection when creating game integrations.
"""
from pathlib import Path
from typing import Any
from wled_controller.core.game_integration.mapping_adapter import (
MappingAdapter,
load_adapter_from_yaml,
)
from wled_controller.utils import get_logger
logger = get_logger(__name__)
# Default directory for community adapter YAML files
_DEFAULT_ADAPTER_DIR = Path(__file__).parent.parent.parent / "data" / "game_adapters"
# Registry of loaded community adapters (adapter_type -> MappingAdapter instance)
_community_adapters: dict[str, MappingAdapter] = {}
def get_community_adapter_dir(custom_dir: str | Path | None = None) -> Path:
"""Return the community adapter directory path.
Args:
custom_dir: Optional override path. Falls back to built-in data/game_adapters/.
Returns:
Path to the adapter directory.
"""
if custom_dir:
return Path(custom_dir)
return _DEFAULT_ADAPTER_DIR
def load_community_adapters(
adapter_dir: str | Path | None = None,
) -> dict[str, MappingAdapter]:
"""Scan a directory for YAML adapter files and load them.
Args:
adapter_dir: Directory to scan. Defaults to data/game_adapters/.
Returns:
Dict mapping adapter type keys to MappingAdapter instances.
"""
directory = get_community_adapter_dir(adapter_dir)
if not directory.exists():
logger.info("Community adapter directory not found: %s", directory)
return {}
loaded: dict[str, MappingAdapter] = {}
yaml_files = sorted(directory.glob("*.yaml")) + sorted(directory.glob("*.yml"))
for yaml_path in yaml_files:
try:
adapter = load_adapter_from_yaml(yaml_path)
# Use filename stem as the adapter key
adapter_key = f"community_{yaml_path.stem}"
loaded[adapter_key] = adapter
logger.info(
"Loaded community adapter '%s' (%s) from %s",
adapter.name,
adapter.game,
yaml_path.name,
)
except (ValueError, FileNotFoundError) as exc:
logger.warning("Failed to load community adapter %s: %s", yaml_path.name, exc)
return loaded
def register_community_adapters(
adapter_dir: str | Path | None = None,
) -> int:
"""Load community adapters and store them in the module-level registry.
Args:
adapter_dir: Directory to scan. Defaults to data/game_adapters/.
Returns:
Number of adapters successfully loaded.
"""
global _community_adapters
_community_adapters = load_community_adapters(adapter_dir)
count = len(_community_adapters)
if count > 0:
logger.info("Registered %d community adapter(s)", count)
return count
def get_community_adapters() -> dict[str, MappingAdapter]:
"""Return all loaded community adapters.
Returns:
Dict mapping adapter keys to MappingAdapter instances.
"""
return dict(_community_adapters)
def get_community_adapter(adapter_key: str) -> MappingAdapter | None:
"""Look up a community adapter by its key.
Args:
adapter_key: The adapter key (e.g. 'community_minecraft').
Returns:
The MappingAdapter instance, or None if not found.
"""
return _community_adapters.get(adapter_key)
def get_community_adapter_info() -> list[dict[str, Any]]:
"""Return metadata for all loaded community adapters.
Returns:
List of dicts with adapter_type, display_name, game_name, supported_events.
"""
result = []
for adapter_key, adapter in _community_adapters.items():
result.append(
{
"adapter_type": adapter_key,
"display_name": adapter.name,
"game_name": adapter.game,
"supported_events": adapter.supported_events,
"source": "community",
}
)
return result
def clear_community_adapters() -> None:
"""Clear all loaded community adapters (for testing)."""
global _community_adapters
_community_adapters = {}
@@ -0,0 +1,138 @@
"""Thread-safe game event pub/sub bus.
The GameEventBus dispatches GameEvent instances to subscribers. Supports
type-specific subscriptions (receive only events of a given type) and
wildcard subscriptions (receive all events, useful for diagnostics).
"""
import collections
import threading
import uuid
from typing import Callable
from wled_controller.core.game_integration.events import GameEvent
from wled_controller.utils import get_logger
logger = get_logger(__name__)
Callback = Callable[[GameEvent], None]
class GameEventBus:
"""In-process pub/sub bus for game events.
Thread-safe: publish() and subscribe/unsubscribe can be called from
any thread concurrently.
"""
def __init__(self, recent_maxlen: int = 100) -> None:
self._lock = threading.Lock()
# event_type -> {sub_id: callback}
self._subscribers: dict[str, dict[str, Callback]] = {}
# Wildcard subscribers receive every event
self._wildcard_subscribers: dict[str, Callback] = {}
# Recent events ring buffer
self._recent_events: collections.deque[GameEvent] = collections.deque(
maxlen=recent_maxlen,
)
# Stats: event_type -> count
self._event_counts: dict[str, int] = {}
self._last_event_timestamp: float | None = None
def publish(self, event: GameEvent) -> None:
"""Dispatch an event to all matching subscribers.
Callbacks are invoked synchronously under the lock released —
a snapshot of subscribers is taken while holding the lock, then
callbacks are called outside the lock to avoid deadlocks.
"""
with self._lock:
self._recent_events.append(event)
self._event_counts[event.event_type] = self._event_counts.get(event.event_type, 0) + 1
self._last_event_timestamp = event.timestamp
# Snapshot subscribers for this event type + wildcards
type_subs = dict(self._subscribers.get(event.event_type, {}))
wildcard_subs = dict(self._wildcard_subscribers)
# Invoke outside lock
for sub_id, callback in type_subs.items():
try:
callback(event)
except Exception:
logger.exception(f"Error in event subscriber {sub_id}")
for sub_id, callback in wildcard_subs.items():
try:
callback(event)
except Exception:
logger.exception(f"Error in wildcard subscriber {sub_id}")
def subscribe(self, event_type: str, callback: Callback) -> str:
"""Subscribe to events of a specific type.
Returns:
A subscription ID that can be passed to unsubscribe().
"""
sub_id = f"sub_{uuid.uuid4().hex[:8]}"
with self._lock:
if event_type not in self._subscribers:
self._subscribers[event_type] = {}
self._subscribers[event_type][sub_id] = callback
logger.debug(f"Subscribed {sub_id} to event type '{event_type}'")
return sub_id
def subscribe_all(self, callback: Callback) -> str:
"""Subscribe to all events (wildcard).
Returns:
A subscription ID that can be passed to unsubscribe().
"""
sub_id = f"sub_{uuid.uuid4().hex[:8]}"
with self._lock:
self._wildcard_subscribers[sub_id] = callback
logger.debug(f"Subscribed {sub_id} as wildcard listener")
return sub_id
def unsubscribe(self, subscription_id: str) -> bool:
"""Remove a subscription by ID.
Returns:
True if the subscription was found and removed.
"""
with self._lock:
# Check type-specific subscribers
for subs in self._subscribers.values():
if subscription_id in subs:
del subs[subscription_id]
logger.debug(f"Unsubscribed {subscription_id}")
return True
# Check wildcard subscribers
if subscription_id in self._wildcard_subscribers:
del self._wildcard_subscribers[subscription_id]
logger.debug(f"Unsubscribed wildcard {subscription_id}")
return True
return False
def get_recent_events(self, limit: int = 50) -> list[GameEvent]:
"""Return the most recent events (newest last).
Args:
limit: Maximum number of events to return.
"""
with self._lock:
events = list(self._recent_events)
# Return the last `limit` events
return events[-limit:]
def get_stats(self) -> dict:
"""Return diagnostic statistics.
Returns:
Dict with 'event_counts' (per-type) and 'last_event_timestamp'.
"""
with self._lock:
return {
"event_counts": dict(self._event_counts),
"last_event_timestamp": self._last_event_timestamp,
}
@@ -0,0 +1,159 @@
"""Standardized game event model and event vocabulary.
Defines the GameEvent frozen dataclass and a vocabulary of standard event
types that all game adapters map into. Users configure effects against these
universal categories, so a "health < 30% -> flash red" config works across
any supported game.
"""
import time
from dataclasses import dataclass, field
from enum import Enum
from typing import Any
class EventCategory(str, Enum):
"""Top-level grouping for event types."""
RESOURCE = "resource"
COMBAT = "combat"
MATCH_FLOW = "match_flow"
OBJECTIVE = "objective"
STATUS_EFFECT = "status_effect"
TEAM = "team"
class ValueType(str, Enum):
"""Whether an event carries a continuous value or is a discrete trigger."""
CONTINUOUS = "continuous"
TRIGGER = "trigger"
@dataclass(frozen=True)
class EventTypeMetadata:
"""Metadata describing a standard event type."""
name: str
display_name: str
category: EventCategory
value_type: ValueType
default_min: float = 0.0
default_max: float = 1.0
# ── Standard Event Vocabulary ────────────────────────────────────────────
_VOCABULARY: dict[str, EventTypeMetadata] = {}
def _reg(
name: str,
display_name: str,
category: EventCategory,
value_type: ValueType,
default_min: float = 0.0,
default_max: float = 1.0,
) -> str:
"""Register an event type in the vocabulary and return its name."""
_VOCABULARY[name] = EventTypeMetadata(
name=name,
display_name=display_name,
category=category,
value_type=value_type,
default_min=default_min,
default_max=default_max,
)
return name
# Resource (continuous)
HEALTH = _reg("health", "Health", EventCategory.RESOURCE, ValueType.CONTINUOUS)
ARMOR = _reg("armor", "Armor", EventCategory.RESOURCE, ValueType.CONTINUOUS)
SHIELD = _reg("shield", "Shield", EventCategory.RESOURCE, ValueType.CONTINUOUS)
MANA = _reg("mana", "Mana", EventCategory.RESOURCE, ValueType.CONTINUOUS)
ENERGY = _reg("energy", "Energy", EventCategory.RESOURCE, ValueType.CONTINUOUS)
AMMO = _reg("ammo", "Ammo", EventCategory.RESOURCE, ValueType.CONTINUOUS)
GOLD = _reg("gold", "Gold", EventCategory.RESOURCE, ValueType.CONTINUOUS)
FUEL = _reg("fuel", "Fuel", EventCategory.RESOURCE, ValueType.CONTINUOUS)
SPEED = _reg("speed", "Speed", EventCategory.RESOURCE, ValueType.CONTINUOUS)
# Combat (triggers)
KILL = _reg("kill", "Kill", EventCategory.COMBAT, ValueType.TRIGGER)
DEATH = _reg("death", "Death", EventCategory.COMBAT, ValueType.TRIGGER)
ASSIST = _reg("assist", "Assist", EventCategory.COMBAT, ValueType.TRIGGER)
DAMAGE_TAKEN = _reg("damage_taken", "Damage Taken", EventCategory.COMBAT, ValueType.TRIGGER)
DAMAGE_DEALT = _reg("damage_dealt", "Damage Dealt", EventCategory.COMBAT, ValueType.TRIGGER)
# Match flow (triggers)
MATCH_START = _reg("match_start", "Match Start", EventCategory.MATCH_FLOW, ValueType.TRIGGER)
MATCH_END = _reg("match_end", "Match End", EventCategory.MATCH_FLOW, ValueType.TRIGGER)
ROUND_START = _reg("round_start", "Round Start", EventCategory.MATCH_FLOW, ValueType.TRIGGER)
ROUND_END = _reg("round_end", "Round End", EventCategory.MATCH_FLOW, ValueType.TRIGGER)
# Objective
OBJECTIVE_CAPTURED = _reg(
"objective_captured",
"Objective Captured",
EventCategory.OBJECTIVE,
ValueType.TRIGGER,
)
OBJECTIVE_LOST = _reg(
"objective_lost",
"Objective Lost",
EventCategory.OBJECTIVE,
ValueType.TRIGGER,
)
OBJECTIVE_PROGRESS = _reg(
"objective_progress",
"Objective Progress",
EventCategory.OBJECTIVE,
ValueType.CONTINUOUS,
)
# Status effects (triggers)
STUNNED = _reg("stunned", "Stunned", EventCategory.STATUS_EFFECT, ValueType.TRIGGER)
BLINDED = _reg("blinded", "Blinded", EventCategory.STATUS_EFFECT, ValueType.TRIGGER)
BUFFED = _reg("buffed", "Buffed", EventCategory.STATUS_EFFECT, ValueType.TRIGGER)
DEBUFFED = _reg("debuffed", "Debuffed", EventCategory.STATUS_EFFECT, ValueType.TRIGGER)
# Team affiliation (continuous — e.g. team score)
TEAM_A = _reg("team_a", "Team A", EventCategory.TEAM, ValueType.CONTINUOUS)
TEAM_B = _reg("team_b", "Team B", EventCategory.TEAM, ValueType.CONTINUOUS)
def get_event_vocabulary() -> dict[str, EventTypeMetadata]:
"""Return a copy of the full event vocabulary."""
return dict(_VOCABULARY)
def get_event_metadata(event_type: str) -> EventTypeMetadata | None:
"""Look up metadata for a standard event type."""
return _VOCABULARY.get(event_type)
def is_known_event_type(event_type: str) -> bool:
"""Check whether an event type is in the standard vocabulary."""
return event_type in _VOCABULARY
# ── GameEvent dataclass ──────────────────────────────────────────────────
@dataclass(frozen=True)
class GameEvent:
"""A single game event emitted by an adapter.
Attributes:
adapter_id: Identifier of the adapter that produced this event.
event_type: Standard event type string from the vocabulary.
value: Normalized value in 0.0-1.0 range (1.0 for triggers).
raw_data: Original game-specific data for debugging.
timestamp: Monotonic timestamp (time.monotonic()).
"""
adapter_id: str
event_type: str
value: float = 1.0
raw_data: dict[str, Any] = field(default_factory=dict)
timestamp: float = field(default_factory=time.monotonic)
@@ -0,0 +1,299 @@
"""YAML-driven mapping adapter for community game integrations.
Allows community contributors to define game adapters as YAML files
without writing Python code. A YAML adapter file specifies:
- Adapter metadata (name, game, protocol)
- A list of mappings from JSON paths to standard event types
- Optional auth configuration
The MappingAdapter class is a concrete GameAdapter whose behavior is
entirely driven by the parsed YAML definition.
"""
import time
from pathlib import Path
from typing import Any
import yaml
from wled_controller.core.game_integration.base_adapter import GameAdapter
from wled_controller.core.game_integration.events import (
GameEvent,
is_known_event_type,
)
from wled_controller.utils import get_logger
logger = get_logger(__name__)
# Valid trigger modes for mapping entries
VALID_TRIGGER_MODES = frozenset({"on_change", "on_increase", "on_decrease", "on_value"})
def _resolve_json_path(data: dict[str, Any], path: str) -> Any | None:
"""Resolve a dot-separated JSON path against a nested dict.
Supports simple dot notation: "player.state.health"
Returns None if the path cannot be resolved.
"""
parts = path.split(".")
current: Any = data
for part in parts:
if isinstance(current, dict) and part in current:
current = current[part]
else:
return None
return current
def validate_adapter_yaml(data: dict[str, Any]) -> list[str]:
"""Validate a parsed YAML adapter definition.
Args:
data: Parsed YAML dict.
Returns:
List of validation error strings (empty = valid).
"""
errors: list[str] = []
# Required top-level fields
if not isinstance(data.get("name"), str) or not data["name"].strip():
errors.append("Missing or empty 'name' field")
if not isinstance(data.get("game"), str) or not data["game"].strip():
errors.append("Missing or empty 'game' field")
if data.get("protocol") not in ("webhook", "poll"):
errors.append("'protocol' must be 'webhook' or 'poll'")
# Mappings
mappings = data.get("mappings")
if not isinstance(mappings, list) or len(mappings) == 0:
errors.append("'mappings' must be a non-empty list")
return errors
for i, mapping in enumerate(mappings):
prefix = f"mappings[{i}]"
if not isinstance(mapping, dict):
errors.append(f"{prefix}: must be a dict")
continue
if not isinstance(mapping.get("source_path"), str) or not mapping["source_path"].strip():
errors.append(f"{prefix}: missing or empty 'source_path'")
event = mapping.get("event")
if not isinstance(event, str) or not event.strip():
errors.append(f"{prefix}: missing or empty 'event'")
elif not is_known_event_type(event):
errors.append(f"{prefix}: unknown event type '{event}'")
trigger = mapping.get("trigger", "on_change")
if trigger not in VALID_TRIGGER_MODES:
errors.append(
f"{prefix}: invalid trigger mode '{trigger}', "
f"must be one of {sorted(VALID_TRIGGER_MODES)}"
)
# min/max are optional but must be numeric if present
for field in ("min", "max"):
if field in mapping:
if not isinstance(mapping[field], (int, float)):
errors.append(f"{prefix}: '{field}' must be numeric")
return errors
class MappingAdapter(GameAdapter):
"""A game adapter whose behavior is defined by a YAML mapping file.
Unlike built-in adapters (classmethods on a class), MappingAdapter
instances carry per-adapter state from their YAML definition.
The classmethod interface is implemented by delegating to instance
data stored on dynamically created subclasses.
"""
ADAPTER_TYPE = "mapping"
DISPLAY_NAME = "YAML Mapping Adapter"
GAME_NAME = "Generic"
SUPPORTED_EVENTS: list[str] = []
def __init__(self, definition: dict[str, Any]) -> None:
self._definition = definition
self._name: str = definition["name"]
self._game: str = definition["game"]
self._protocol: str = definition["protocol"]
self._mappings: list[dict[str, Any]] = definition["mappings"]
self._auth: dict[str, Any] = definition.get("auth", {})
self._supported_events = list({m["event"] for m in self._mappings})
@property
def name(self) -> str:
return self._name
@property
def game(self) -> str:
return self._game
@property
def protocol(self) -> str:
return self._protocol
@property
def supported_events(self) -> list[str]:
return list(self._supported_events)
def parse_payload( # type: ignore[override]
self,
payload: dict[str, Any],
adapter_config: dict[str, Any],
prev_state: dict[str, Any],
) -> tuple[list[GameEvent], dict[str, Any]]:
"""Parse a JSON payload using the YAML mapping definitions.
For continuous events, values are normalized to 0.0-1.0 using
the mapping's min/max range. For triggers, diff-based detection
compares against prev_state.
"""
events: list[GameEvent] = []
new_state = dict(prev_state)
adapter_id = adapter_config.get("adapter_id", self._name)
now = time.monotonic()
for mapping in self._mappings:
source_path: str = mapping["source_path"]
event_type: str = mapping["event"]
trigger_mode: str = mapping.get("trigger", "on_change")
range_min: float = float(mapping.get("min", 0))
range_max: float = float(mapping.get("max", 100))
raw_value = _resolve_json_path(payload, source_path)
if raw_value is None:
continue
try:
numeric_value = float(raw_value)
except (TypeError, ValueError):
# Non-numeric: treat presence as a trigger with value 1.0
events.append(
GameEvent(
adapter_id=adapter_id,
event_type=event_type,
value=1.0,
raw_data={"source_path": source_path, "raw": raw_value},
timestamp=now,
)
)
continue
prev_value = prev_state.get(source_path)
new_state[source_path] = numeric_value
# Determine whether to emit based on trigger mode
should_emit = False
if trigger_mode == "on_change":
should_emit = prev_value is None or numeric_value != prev_value
elif trigger_mode == "on_increase":
should_emit = prev_value is not None and numeric_value > prev_value
elif trigger_mode == "on_decrease":
should_emit = prev_value is not None and numeric_value < prev_value
elif trigger_mode == "on_value":
# Always emit when value is present
should_emit = True
if not should_emit:
continue
# Normalize to 0.0-1.0
range_span = range_max - range_min
if range_span > 0:
normalized = max(0.0, min(1.0, (numeric_value - range_min) / range_span))
else:
normalized = 1.0
events.append(
GameEvent(
adapter_id=adapter_id,
event_type=event_type,
value=normalized,
raw_data={"source_path": source_path, "raw": numeric_value},
timestamp=now,
)
)
return events, new_state
def validate_auth( # type: ignore[override]
self,
headers: dict[str, str],
payload: dict[str, Any],
adapter_config: dict[str, Any],
) -> bool:
"""Validate authentication using the YAML auth config.
Supports 'header' auth type: checks that a specified header
matches an expected value from adapter_config.
"""
auth_type = self._auth.get("type")
if not auth_type:
# No auth configured — accept all
return True
if auth_type == "header":
header_name = self._auth.get("header", "")
expected_key = "auth_token"
expected_value = adapter_config.get(expected_key, "")
actual_value = headers.get(header_name, "")
return bool(expected_value and actual_value == expected_value)
logger.warning(f"Unknown auth type '{auth_type}' in mapping adapter '{self._name}'")
return False
def get_config_schema(self) -> dict[str, Any]: # type: ignore[override]
"""Return config schema based on YAML auth requirements."""
properties: dict[str, Any] = {}
if self._auth.get("type") == "header":
properties["auth_token"] = {
"type": "string",
"title": "Auth Token",
"description": f"Value for the {self._auth.get('header', '')} header",
}
return {"type": "object", "properties": properties}
def get_setup_instructions(self) -> str: # type: ignore[override]
"""Return setup instructions from the YAML definition."""
return self._definition.get(
"setup_instructions",
f"Configure {self._game} to send data to the webhook endpoint.",
)
def load_adapter_from_yaml(path: str | Path) -> MappingAdapter:
"""Load a MappingAdapter from a YAML file.
Args:
path: Path to the YAML adapter definition file.
Returns:
A configured MappingAdapter instance.
Raises:
FileNotFoundError: If the file does not exist.
ValueError: If the YAML is invalid.
"""
path = Path(path)
if not path.exists():
raise FileNotFoundError(f"Adapter YAML file not found: {path}")
with open(path, encoding="utf-8") as f:
data = yaml.safe_load(f)
if not isinstance(data, dict):
raise ValueError(f"Adapter YAML must be a dict, got {type(data).__name__}")
errors = validate_adapter_yaml(data)
if errors:
raise ValueError(
f"Invalid adapter YAML '{path.name}':\n" + "\n".join(f" - {e}" for e in errors)
)
adapter = MappingAdapter(data)
logger.info(f"Loaded mapping adapter '{adapter.name}' for {adapter.game} from {path.name}")
return adapter
@@ -0,0 +1,208 @@
"""Built-in effect presets for game integrations.
Presets are read-only template configurations that can be applied to any
game integration. Each preset ships a curated set of event-to-effect
mappings tuned for a specific genre (FPS, MOBA, racing, generic).
Users can apply a preset via API, which merges the preset's mappings into
the integration's event_mappings list. After applying, mappings can be
freely edited.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import List
from wled_controller.storage.game_integration import EventMapping
@dataclass(frozen=True)
class EffectPreset:
"""A built-in effect preset.
Attributes:
key: Unique identifier for the preset (e.g. 'fps_combat').
name: Display name (e.g. 'FPS Combat').
description: One-line description of what the preset does.
target_game_types: Genre tags indicating which games this suits.
event_mappings: List of pre-configured event-to-effect mappings.
"""
key: str
name: str
description: str
target_game_types: List[str] = field(default_factory=list)
event_mappings: List[EventMapping] = field(default_factory=list)
# ── Built-in Presets ────────────────────────────────────────────────────
_PRESETS: dict[str, EffectPreset] = {}
def _reg(preset: EffectPreset) -> None:
"""Register a preset in the module-level registry."""
_PRESETS[preset.key] = preset
_reg(
EffectPreset(
key="fps_combat",
name="FPS Combat",
description="Health glow, kill flash, death pulse, round start sweep",
target_game_types=["fps"],
event_mappings=[
EventMapping(
event_type="health",
effect="breathing",
color=[255, 50, 50],
duration_ms=2000,
intensity=0.6,
priority=3,
),
EventMapping(
event_type="kill",
effect="flash",
color=[0, 255, 0],
duration_ms=400,
intensity=1.0,
priority=8,
),
EventMapping(
event_type="death",
effect="pulse",
color=[255, 0, 0],
duration_ms=1500,
intensity=1.0,
priority=10,
),
EventMapping(
event_type="round_start",
effect="sweep",
color=[0, 100, 255],
duration_ms=800,
intensity=0.8,
priority=5,
),
],
)
)
_reg(
EffectPreset(
key="moba_health",
name="MOBA Health",
description="Health gradient green-yellow-red, mana blue glow, death fade to black",
target_game_types=["moba"],
event_mappings=[
EventMapping(
event_type="health",
effect="color_shift",
color=[0, 255, 0],
duration_ms=1000,
intensity=0.7,
priority=4,
),
EventMapping(
event_type="mana",
effect="breathing",
color=[50, 100, 255],
duration_ms=2000,
intensity=0.5,
priority=3,
),
EventMapping(
event_type="death",
effect="pulse",
color=[20, 20, 20],
duration_ms=2500,
intensity=1.0,
priority=10,
),
],
)
)
_reg(
EffectPreset(
key="racing",
name="Racing",
description="Speed-based color temperature, boost rainbow flash",
target_game_types=["racing"],
event_mappings=[
EventMapping(
event_type="speed",
effect="color_shift",
color=[255, 120, 0],
duration_ms=500,
intensity=0.8,
priority=4,
),
EventMapping(
event_type="buffed",
effect="flash",
color=[128, 0, 255],
duration_ms=300,
intensity=1.0,
priority=9,
),
],
)
)
_reg(
EffectPreset(
key="generic_alert",
name="Generic Alert",
description="White flash on any trigger event",
target_game_types=["any"],
event_mappings=[
EventMapping(
event_type="kill",
effect="flash",
color=[255, 255, 255],
duration_ms=400,
intensity=1.0,
priority=5,
),
EventMapping(
event_type="death",
effect="flash",
color=[255, 255, 255],
duration_ms=400,
intensity=1.0,
priority=5,
),
EventMapping(
event_type="round_start",
effect="flash",
color=[255, 255, 255],
duration_ms=400,
intensity=1.0,
priority=5,
),
EventMapping(
event_type="objective_captured",
effect="flash",
color=[255, 255, 255],
duration_ms=400,
intensity=1.0,
priority=5,
),
],
)
)
# ── Public API ──────────────────────────────────────────────────────────
def get_all_presets() -> list[EffectPreset]:
"""Return all built-in presets."""
return list(_PRESETS.values())
def get_preset(key: str) -> EffectPreset | None:
"""Look up a preset by key."""
return _PRESETS.get(key)
@@ -0,0 +1,156 @@
"""Steam installation and game config path detection.
Finds the Steam root directory via Windows registry, common paths, and
environment variables. Locates game-specific cfg directories by checking
Steam library folders (including multiple library locations from
libraryfolders.vdf).
"""
import platform
import re
from pathlib import Path
from wled_controller.utils import get_logger
logger = get_logger(__name__)
# Game IDs and relative cfg paths within steamapps/common/
_GAME_PATHS: dict[str, dict[str, str]] = {
"cs2": {
"app_id": "730",
"install_dir": "Counter-Strike Global Offensive",
"cfg_subpath": "game/csgo/cfg",
},
"dota2": {
"app_id": "570",
"install_dir": "dota 2 beta",
"cfg_subpath": "game/dota/cfg",
},
}
def find_steam_root() -> Path | None:
"""Detect the Steam installation directory.
Strategy:
1. Windows: read HKCU\\Software\\Valve\\Steam\\SteamPath
2. Fall back to common paths per platform
3. Check STEAM_DIR environment variable
Returns:
Path to Steam root, or None if not found.
"""
import os
system = platform.system()
# ── Windows registry ──
if system == "Windows":
try:
import winreg
key = winreg.OpenKey(winreg.HKEY_CURRENT_USER, r"Software\Valve\Steam")
steam_path, _ = winreg.QueryValueEx(key, "SteamPath")
winreg.CloseKey(key)
p = Path(str(steam_path))
if p.is_dir():
logger.debug("Steam found via registry: %s", p)
return p
except (OSError, ImportError):
pass
# ── Common paths ──
common_paths: list[Path] = []
if system == "Windows":
common_paths = [
Path("C:/Program Files (x86)/Steam"),
Path("C:/Program Files/Steam"),
]
elif system == "Linux":
home = Path.home()
common_paths = [
home / ".steam" / "root",
home / ".steam" / "steam",
home / ".local" / "share" / "Steam",
]
elif system == "Darwin":
common_paths = [
Path.home() / "Library" / "Application Support" / "Steam",
]
for candidate in common_paths:
if candidate.is_dir():
logger.debug("Steam found at common path: %s", candidate)
return candidate
# ── Environment variable fallback ──
env_dir = os.environ.get("STEAM_DIR")
if env_dir:
p = Path(env_dir)
if p.is_dir():
logger.debug("Steam found via STEAM_DIR env: %s", p)
return p
logger.debug("Steam installation not found")
return None
def _parse_library_folders(steam_root: Path) -> list[Path]:
"""Parse libraryfolders.vdf to find all Steam library locations.
Returns a list of library root paths (each containing a steamapps/ dir).
The steam_root itself is always included as the first entry.
"""
libraries: list[Path] = [steam_root]
vdf_path = steam_root / "steamapps" / "libraryfolders.vdf"
if not vdf_path.is_file():
# Older Steam versions use config/libraryfolders.vdf
vdf_path = steam_root / "config" / "libraryfolders.vdf"
if not vdf_path.is_file():
return libraries
try:
content = vdf_path.read_text(encoding="utf-8", errors="replace")
# Match "path" entries in the VDF file
# Format: "path" "C:\\SteamLibrary"
for match in re.finditer(r'"path"\s+"([^"]+)"', content):
lib_path = Path(match.group(1).replace("\\\\", "\\"))
if lib_path.is_dir() and lib_path not in libraries:
libraries.append(lib_path)
except OSError as e:
logger.warning("Failed to read libraryfolders.vdf: %s", e)
return libraries
def find_game_cfg_path(game: str) -> Path | None:
"""Find the cfg directory for a supported game.
Args:
game: Game identifier ("cs2" or "dota2").
Returns:
Path to the game's cfg directory, or None if not found.
"""
game_info = _GAME_PATHS.get(game)
if not game_info:
logger.warning("Unknown game identifier: %s", game)
return None
steam_root = find_steam_root()
if not steam_root:
return None
libraries = _parse_library_folders(steam_root)
for lib in libraries:
cfg_path = (
lib / "steamapps" / "common" / game_info["install_dir"] / game_info["cfg_subpath"]
)
if cfg_path.is_dir():
logger.debug("Found %s cfg at: %s", game, cfg_path)
return cfg_path
logger.debug("Game cfg not found for %s in any library folder", game)
return None
@@ -24,6 +24,7 @@ from wled_controller.core.processing.api_input_stream import ApiInputColorStripS
from wled_controller.core.processing.notification_stream import NotificationColorStripStream
from wled_controller.core.processing.daylight_stream import DaylightColorStripStream
from wled_controller.core.processing.candlelight_stream import CandlelightColorStripStream
from wled_controller.core.processing.game_event_stream import GameEventColorStripStream
from wled_controller.utils import get_logger
logger = get_logger(__name__)
@@ -38,6 +39,7 @@ _SIMPLE_STREAM_MAP = {
"notification": NotificationColorStripStream,
"daylight": DaylightColorStripStream,
"candlelight": CandlelightColorStripStream,
"game_event": GameEventColorStripStream,
}
@@ -86,6 +88,7 @@ class ColorStripStreamManager:
gradient_store=None,
weather_manager=None,
asset_store=None,
game_event_bus=None,
):
"""
Args:
@@ -97,6 +100,7 @@ class ColorStripStreamManager:
value_stream_manager: ValueStreamManager for per-layer brightness sources
cspt_store: ColorStripProcessingTemplateStore for per-layer filter chains
gradient_store: GradientStore for resolving gradient entity references
game_event_bus: GameEventBus for game event stream subscriptions
"""
self._color_strip_store = color_strip_store
self._live_stream_manager = live_stream_manager
@@ -109,6 +113,7 @@ class ColorStripStreamManager:
self._gradient_store = gradient_store
self._weather_manager = weather_manager
self._asset_store = asset_store
self._game_event_bus = game_event_bus
self._streams: Dict[str, _ColorStripEntry] = {}
def _inject_clock(self, css_stream, source) -> Optional[str]:
@@ -273,6 +278,9 @@ class ColorStripStreamManager:
# Inject asset store for notification sound playback
if self._asset_store and hasattr(css_stream, "set_asset_store"):
css_stream.set_asset_store(self._asset_store)
# Inject game event bus for game event streams
if self._game_event_bus and hasattr(css_stream, "set_event_bus"):
css_stream.set_event_bus(self._game_event_bus)
# Inject sync clock runtime if source references a clock
acquired_clock_id = self._inject_clock(css_stream, source)
css_stream.start()
@@ -0,0 +1,440 @@
"""Game event color strip stream — renders LED effects on game events.
Subscribes to a GameEventBus and renders animated LED effects (flash, pulse,
sweep, color_shift, breathing) when matching game events arrive. When idle,
outputs the configured idle_color.
Thread-safe: event callbacks arrive from the EventBus dispatch thread while
get_latest_colors() is called from the target processor thread.
Uses a background render loop at 30 FPS with double-buffered output.
"""
import collections
import math
import threading
import time
from typing import Optional
import numpy as np
from wled_controller.core.game_integration.event_bus import GameEventBus
from wled_controller.core.game_integration.events import GameEvent
from wled_controller.core.processing.color_strip_stream import ColorStripStream
from wled_controller.storage.bindable import bcolor
from wled_controller.storage.game_integration import EventMapping
from wled_controller.utils import get_logger
logger = get_logger(__name__)
class GameEventColorStripStream(ColorStripStream):
"""Color strip stream that renders effects in response to game events.
Supports five effect types:
- flash: linear fade from full brightness to zero
- pulse: smooth bell curve (sin)
- sweep: fill LEDs left-to-right, then fade out
- color_shift: gradual hue rotation from the event color
- breathing: slow sine-wave brightness oscillation
Uses collections.deque for thread-safe event passing and threading.Lock
for the output color buffer. Priority-based layering: higher priority
effects override lower ones (same as notification stream).
"""
def __init__(self, source, event_bus: Optional[GameEventBus] = None) -> None:
self._colors_lock = threading.Lock()
self._running = False
self._thread: Optional[threading.Thread] = None
self._fps = 30
self._frame_time = 1.0 / 30
# Event queue: deque of effect dicts
self._event_queue: collections.deque = collections.deque(maxlen=32)
# Active effect state
self._active_effect: Optional[dict] = None
# EventBus reference and subscription IDs
self._event_bus = event_bus
self._subscription_ids: list[str] = []
self._update_from_source(source)
def _update_from_source(self, source) -> None:
"""Parse config from source dataclass."""
self._idle_color = bcolor(getattr(source, "idle_color", None), [0, 0, 0])
self._game_integration_id = getattr(source, "game_integration_id", "")
self._auto_size = not getattr(source, "led_count", 0)
self._led_count = (
getattr(source, "led_count", 0) if getattr(source, "led_count", 0) > 0 else 1
)
# Parse event_mappings into lookup dict: event_type -> EventMapping
self._mapping_lookup: dict[str, EventMapping] = {}
raw_mappings = getattr(source, "event_mappings", [])
for m in raw_mappings:
if isinstance(m, dict):
try:
mapping = EventMapping.from_dict(m)
self._mapping_lookup[mapping.event_type] = mapping
except (KeyError, TypeError):
logger.warning(f"Skipping invalid event mapping: {m}")
elif isinstance(m, EventMapping):
self._mapping_lookup[m.event_type] = m
with self._colors_lock:
idle = self.resolve_color("idle_color", self._idle_color)
self._colors: Optional[np.ndarray] = np.zeros(
(self._led_count, 3),
dtype=np.uint8,
)
self._colors[:, 0] = idle[0]
self._colors[:, 1] = idle[1]
self._colors[:, 2] = idle[2]
def set_event_bus(self, event_bus: GameEventBus) -> None:
"""Inject or replace the EventBus (called by stream manager)."""
self._event_bus = event_bus
def configure(self, device_led_count: int) -> None:
"""Set LED count from the target device (called on target start)."""
if self._auto_size and device_led_count > 0:
new_count = max(self._led_count, device_led_count)
if new_count != self._led_count:
self._led_count = new_count
with self._colors_lock:
self._colors = np.zeros((new_count, 3), dtype=np.uint8)
logger.debug(
f"GameEventColorStripStream auto-sized to {new_count} LEDs",
)
@property
def target_fps(self) -> int:
return self._fps
def set_capture_fps(self, fps: int) -> None:
self._fps = max(1, min(90, fps))
self._frame_time = 1.0 / self._fps
@property
def is_animated(self) -> bool:
return True
@property
def led_count(self) -> int:
return self._led_count
def start(self) -> None:
if self._running:
return
self._running = True
self._subscribe_to_events()
self._thread = threading.Thread(
target=self._render_loop,
name="css-game-event",
daemon=True,
)
self._thread.start()
logger.info(
f"GameEventColorStripStream started "
f"(leds={self._led_count}, mappings={len(self._mapping_lookup)})",
)
def stop(self) -> None:
self._running = False
self._unsubscribe_from_events()
if self._thread:
self._thread.join(timeout=5.0)
if self._thread.is_alive():
logger.warning(
"GameEventColorStripStream render thread did not terminate within 5s",
)
self._thread = None
logger.info("GameEventColorStripStream stopped")
def get_latest_colors(self) -> Optional[np.ndarray]:
with self._colors_lock:
return self._colors
def update_source(self, source) -> None:
"""Hot-update config from updated source."""
from wled_controller.storage.color_strip_source import GameEventColorStripSource
if isinstance(source, GameEventColorStripSource):
prev_led_count = self._led_count if self._auto_size else None
was_running = self._running
# Re-subscribe if integration changed
old_integration = self._game_integration_id
self._update_from_source(source)
if prev_led_count and self._auto_size:
self._led_count = prev_led_count
with self._colors_lock:
self._colors = np.zeros((self._led_count, 3), dtype=np.uint8)
# If mappings changed, re-subscribe
if was_running and old_integration != self._game_integration_id:
self._unsubscribe_from_events()
self._subscribe_to_events()
logger.info("GameEventColorStripStream params updated in-place")
def set_clock(self, clock) -> None:
"""Set or clear the sync clock runtime (not used for game events)."""
pass
# ── EventBus subscription ────────────────────────────────────────
def _subscribe_to_events(self) -> None:
"""Subscribe to EventBus for all mapped event types."""
if not self._event_bus:
logger.warning(
"GameEventColorStripStream: no EventBus available, "
"events will not trigger effects",
)
return
if not self._mapping_lookup:
return
for event_type in self._mapping_lookup:
sub_id = self._event_bus.subscribe(event_type, self._on_game_event)
self._subscription_ids.append(sub_id)
logger.debug(
f"GameEventColorStripStream subscribed to {len(self._subscription_ids)} "
f"event types",
)
def _unsubscribe_from_events(self) -> None:
"""Unsubscribe all active subscriptions."""
if self._event_bus:
for sub_id in self._subscription_ids:
self._event_bus.unsubscribe(sub_id)
self._subscription_ids.clear()
def _on_game_event(self, event: GameEvent) -> None:
"""Callback from EventBus — enqueue an effect (thread-safe)."""
mapping = self._mapping_lookup.get(event.event_type)
if mapping is None:
return
color = tuple(mapping.color)
self._event_queue.append(
{
"color": color,
"start": time.monotonic(),
"priority": mapping.priority,
"effect": mapping.effect,
"duration_ms": mapping.duration_ms,
"intensity": mapping.intensity,
}
)
# ── Render loop ──────────────────────────────────────────────────
def _render_loop(self) -> None:
"""Background thread rendering at 30 FPS."""
_pool_n = 0
_buf_a = _buf_b = None
_use_a = True
try:
while self._running:
wall_start = time.perf_counter()
frame_time = self._frame_time
try:
# Check for new events — higher priority overrides current
while self._event_queue:
try:
event = self._event_queue.popleft()
if self._active_effect is None or event.get(
"priority", 0
) >= self._active_effect.get("priority", 0):
self._active_effect = event
except IndexError:
break
n = self._led_count
# Reallocate buffers if LED count changed
if n != _pool_n:
_pool_n = n
_buf_a = np.zeros((n, 3), dtype=np.uint8)
_buf_b = np.zeros((n, 3), dtype=np.uint8)
buf = _buf_a if _use_a else _buf_b
_use_a = not _use_a
if self._active_effect is not None:
color = self._active_effect["color"]
start_time = self._active_effect["start"]
elapsed_ms = (time.monotonic() - start_time) * 1000.0
duration_ms = self._active_effect.get("duration_ms", 500)
progress = min(elapsed_ms / max(duration_ms, 1), 1.0)
if progress >= 1.0:
# Effect complete — return to idle
self._active_effect = None
idle = self.resolve_color("idle_color", self._idle_color)
buf[:, 0] = idle[0]
buf[:, 1] = idle[1]
buf[:, 2] = idle[2]
else:
intensity = self._active_effect.get("intensity", 1.0)
self._render_effect(
buf,
n,
color,
progress,
self._active_effect.get("effect", "flash"),
intensity,
)
else:
# Idle: output idle_color
idle = self.resolve_color("idle_color", self._idle_color)
buf[:, 0] = idle[0]
buf[:, 1] = idle[1]
buf[:, 2] = idle[2]
with self._colors_lock:
self._colors = buf
except Exception as e:
logger.error(f"GameEventColorStripStream render error: {e}")
elapsed = time.perf_counter() - wall_start
time.sleep(max(frame_time - elapsed, 0.001))
except Exception as e:
logger.error(
f"Fatal GameEventColorStripStream loop error: {e}",
exc_info=True,
)
finally:
self._running = False
# ── Effect renderers ─────────────────────────────────────────────
def _render_effect(
self,
buf: np.ndarray,
n: int,
color: tuple,
progress: float,
effect: str,
intensity: float,
) -> None:
"""Dispatch to the appropriate effect renderer."""
if effect == "pulse":
self._render_pulse(buf, n, color, progress, intensity)
elif effect == "sweep":
self._render_sweep(buf, n, color, progress, intensity)
elif effect == "color_shift":
self._render_color_shift(buf, n, color, progress, intensity)
elif effect == "breathing":
self._render_breathing(buf, n, color, progress, intensity)
else:
# Default: flash
self._render_flash(buf, n, color, progress, intensity)
def _render_flash(
self,
buf: np.ndarray,
n: int,
color: tuple,
progress: float,
intensity: float,
) -> None:
"""Flash effect: linear fade from full brightness to zero."""
brightness = max(0.0, (1.0 - progress) * intensity)
buf[:, 0] = int(color[0] * brightness)
buf[:, 1] = int(color[1] * brightness)
buf[:, 2] = int(color[2] * brightness)
def _render_pulse(
self,
buf: np.ndarray,
n: int,
color: tuple,
progress: float,
intensity: float,
) -> None:
"""Pulse effect: smooth bell curve (sin)."""
brightness = math.sin(progress * math.pi) * intensity
buf[:, 0] = int(color[0] * brightness)
buf[:, 1] = int(color[1] * brightness)
buf[:, 2] = int(color[2] * brightness)
def _render_sweep(
self,
buf: np.ndarray,
n: int,
color: tuple,
progress: float,
intensity: float,
) -> None:
"""Sweep effect: fill LEDs left-to-right, then fade all."""
if progress < 0.5:
fill_progress = progress * 2.0
fill_pos = int(fill_progress * n)
buf[:] = 0
if fill_pos > 0:
buf[:fill_pos, 0] = int(color[0] * intensity)
buf[:fill_pos, 1] = int(color[1] * intensity)
buf[:fill_pos, 2] = int(color[2] * intensity)
else:
fade_progress = (progress - 0.5) * 2.0
brightness = max(0.0, (1.0 - fade_progress) * intensity)
buf[:, 0] = int(color[0] * brightness)
buf[:, 1] = int(color[1] * brightness)
buf[:, 2] = int(color[2] * brightness)
def _render_color_shift(
self,
buf: np.ndarray,
n: int,
color: tuple,
progress: float,
intensity: float,
) -> None:
"""Color shift effect: gradual hue rotation from the event color.
Rotates the hue by up to 180 degrees over the effect duration while
fading brightness with intensity.
"""
import colorsys
r, g, b = color[0] / 255.0, color[1] / 255.0, color[2] / 255.0
h, s, v = colorsys.rgb_to_hsv(r, g, b)
# Rotate hue by up to 0.5 (180 degrees)
shifted_h = (h + progress * 0.5) % 1.0
# Fade brightness over time
shifted_v = v * intensity * max(0.0, 1.0 - progress * 0.5)
sr, sg, sb = colorsys.hsv_to_rgb(shifted_h, s, shifted_v)
buf[:, 0] = min(255, int(sr * 255))
buf[:, 1] = min(255, int(sg * 255))
buf[:, 2] = min(255, int(sb * 255))
def _render_breathing(
self,
buf: np.ndarray,
n: int,
color: tuple,
progress: float,
intensity: float,
) -> None:
"""Breathing effect: slow sine-wave brightness oscillation.
Performs two full breathing cycles over the effect duration.
"""
# Two full sin cycles over the duration
brightness = (math.sin(progress * 4 * math.pi - math.pi / 2) + 1.0) / 2.0
brightness *= intensity
buf[:, 0] = int(color[0] * brightness)
buf[:, 1] = int(color[1] * brightness)
buf[:, 2] = int(color[2] * brightness)
@@ -72,6 +72,7 @@ class ProcessorDependencies:
weather_manager: Optional[WeatherManager] = None
asset_store: Optional[AssetStore] = None
ha_manager: Optional[Any] = None # HomeAssistantManager
game_event_bus: Optional[Any] = None # GameEventBus
@dataclass
@@ -151,6 +152,7 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
gradient_store=deps.gradient_store,
weather_manager=deps.weather_manager,
asset_store=deps.asset_store,
game_event_bus=deps.game_event_bus,
)
self._value_stream_manager = (
ValueStreamManager(
@@ -162,6 +164,7 @@ class ProcessorManager(AutoRestartMixin, DeviceHealthMixin, DeviceTestModeMixin)
ha_manager=deps.ha_manager,
css_stream_manager=self._color_strip_stream_manager,
gradient_store=deps.gradient_store,
event_bus=deps.game_event_bus,
)
if deps.value_source_store
else None
@@ -33,6 +33,7 @@ from wled_controller.utils import get_logger
if TYPE_CHECKING:
from wled_controller.core.audio.audio_capture import AudioCaptureManager
from wled_controller.core.game_integration.event_bus import GameEventBus
from wled_controller.core.home_assistant.ha_manager import HomeAssistantManager
from wled_controller.core.processing.color_strip_stream_manager import ColorStripStreamManager
from wled_controller.core.processing.live_stream_manager import LiveStreamManager
@@ -777,6 +778,18 @@ class AdaptiveTimeColorValueStream(ValueStream):
# HA Entity
# ---------------------------------------------------------------------------
# Common HA boolean states mapped to numeric values for value sources
_HA_BOOL_MAP: Dict[str, float] = {
"on": 1.0,
"off": 0.0,
"home": 1.0,
"away": 0.0,
"open": 1.0,
"closed": 0.0,
"true": 1.0,
"false": 0.0,
}
class HAEntityValueStream(ValueStream):
"""Reads a numeric value from a Home Assistant entity state or attribute.
@@ -855,7 +868,11 @@ class HAEntityValueStream(ValueStream):
raw_str = attrs.get(self._attribute, getattr(state, "state", "0"))
else:
raw_str = getattr(state, "state", "0")
raw = float(raw_str)
raw_lower = raw_str.lower() if isinstance(raw_str, str) else raw_str
if raw_lower in _HA_BOOL_MAP:
raw = _HA_BOOL_MAP[raw_lower]
else:
raw = float(raw_str)
except (ValueError, TypeError):
return self._prev_value if self._prev_value is not None else 0.0
@@ -1394,6 +1411,7 @@ class ValueStreamManager:
ha_manager: Optional["HomeAssistantManager"] = None,
css_stream_manager: Optional["ColorStripStreamManager"] = None,
gradient_store: Optional[Any] = None,
event_bus: Optional["GameEventBus"] = None,
):
self._value_source_store = value_source_store
self._audio_capture_manager = audio_capture_manager
@@ -1403,6 +1421,7 @@ class ValueStreamManager:
self._ha_manager = ha_manager
self._css_stream_manager = css_stream_manager
self._gradient_store = gradient_store
self._event_bus = event_bus
self._streams: Dict[str, ValueStream] = {} # vs_id → stream
self._ref_counts: Dict[str, int] = {} # vs_id → ref count
@@ -1474,6 +1493,7 @@ class ValueStreamManager:
AudioValueSource,
CSSExtractValueSource,
DaylightValueSource,
GameEventValueSource,
GradientMapValueSource,
HAEntityValueSource,
StaticValueSource,
@@ -1588,5 +1608,20 @@ class ValueStreamManager:
smoothing=source.smoothing,
)
if isinstance(source, GameEventValueSource):
from wled_controller.core.value_sources.game_event_value_source import (
GameEventValueStream,
)
return GameEventValueStream(
event_type=source.event_type,
min_game_value=source.min_game_value,
max_game_value=source.max_game_value,
smoothing=source.smoothing,
default_value=source.default_value,
timeout=source.timeout,
event_bus=self._event_bus,
)
# Fallback
return StaticValueStream(value=1.0)
@@ -0,0 +1,142 @@
"""GameEventValueStream — value stream driven by game events.
Subscribes to the GameEventBus for a configured event type, normalizes
incoming game values to 0.0-1.0 using min/max mapping, applies optional
EMA smoothing, and reverts to a default value on timeout.
Thread-safe: the EventBus callback runs on the publisher's thread while
get_value() is called from the render thread.
"""
from __future__ import annotations
import threading
import time
from typing import TYPE_CHECKING, Optional
from wled_controller.core.processing.value_stream import ValueStream
from wled_controller.utils import get_logger
if TYPE_CHECKING:
from wled_controller.core.game_integration.event_bus import GameEventBus
from wled_controller.core.game_integration.events import GameEvent
from wled_controller.storage.value_source import GameEventValueSource
logger = get_logger(__name__)
class GameEventValueStream(ValueStream):
"""Runtime resolver that exposes game metrics as 0.0-1.0 scalars.
Subscribes to events of a specific type on the GameEventBus,
normalizes raw game values, applies EMA smoothing, and handles
timeout (reverts to default_value when no events arrive).
"""
def __init__(
self,
event_type: str,
min_game_value: float = 0.0,
max_game_value: float = 100.0,
smoothing: float = 0.0,
default_value: float = 0.5,
timeout: float = 5.0,
event_bus: Optional["GameEventBus"] = None,
) -> None:
self._event_type = event_type
self._min_game = min_game_value
self._max_game = max_game_value
self._smoothing = max(0.0, min(1.0, smoothing))
self._default_value = max(0.0, min(1.0, default_value))
self._timeout = max(0.0, timeout)
self._event_bus = event_bus
self._lock = threading.Lock()
self._current_value: float = self._default_value
self._last_event_time: Optional[float] = None
self._subscription_id: Optional[str] = None
self._has_received_event: bool = False
def start(self) -> None:
"""Subscribe to the EventBus for the configured event type."""
if self._event_bus is not None:
self._subscription_id = self._event_bus.subscribe(
self._event_type,
self._on_event,
)
logger.info(
"GameEventValueStream started (event_type=%s, sub=%s)",
self._event_type,
self._subscription_id,
)
def stop(self) -> None:
"""Unsubscribe from the EventBus and reset state."""
if self._event_bus is not None and self._subscription_id is not None:
self._event_bus.unsubscribe(self._subscription_id)
logger.info(
"GameEventValueStream stopped (event_type=%s, sub=%s)",
self._event_type,
self._subscription_id,
)
self._subscription_id = None
with self._lock:
self._current_value = self._default_value
self._last_event_time = None
self._has_received_event = False
def get_value(self) -> float:
"""Return current normalized value (0.0-1.0), or default if timed out."""
with self._lock:
if not self._has_received_event:
return self._default_value
if self._timeout > 0.0 and self._last_event_time is not None:
elapsed = time.monotonic() - self._last_event_time
if elapsed > self._timeout:
return self._default_value
return self._current_value
def get_color(self) -> tuple:
"""Game event value source only provides scalars, not colors."""
raise NotImplementedError("GameEventValueStream does not produce colors")
def update_source(self, source: "GameEventValueSource") -> None:
"""Hot-update parameters from a modified GameEventValueSource config."""
from wled_controller.storage.value_source import GameEventValueSource as GEVS
if not isinstance(source, GEVS):
return
with self._lock:
self._min_game = source.min_game_value
self._max_game = source.max_game_value
self._smoothing = max(0.0, min(1.0, source.smoothing))
self._default_value = max(0.0, min(1.0, source.default_value))
self._timeout = max(0.0, source.timeout)
def _on_event(self, event: "GameEvent") -> None:
"""EventBus callback — normalize and apply smoothing.
Called from the publisher's thread; must be thread-safe.
"""
raw_value = event.value
normalized = self._normalize(raw_value)
with self._lock:
if self._smoothing > 0.0 and self._has_received_event:
alpha = 1.0 - self._smoothing
normalized = alpha * normalized + self._smoothing * self._current_value
self._current_value = normalized
self._last_event_time = time.monotonic()
self._has_received_event = True
def _normalize(self, raw_value: float) -> float:
"""Map a raw game value to the 0.0-1.0 range using min/max."""
game_range = self._max_game - self._min_game
if abs(game_range) < 1e-9:
return 0.5
normalized = (raw_value - self._min_game) / game_range
return max(0.0, min(1.0, normalized))
+10
View File
@@ -44,6 +44,10 @@ from wled_controller.core.weather.weather_manager import WeatherManager
from wled_controller.storage.home_assistant_store import HomeAssistantStore
from wled_controller.core.home_assistant.ha_manager import HomeAssistantManager
from wled_controller.core.automations.automation_engine import AutomationEngine
from wled_controller.storage.game_integration_store import GameIntegrationStore
from wled_controller.core.game_integration.event_bus import GameEventBus
import wled_controller.core.game_integration.adapters # noqa: F401 — register built-in adapters
from wled_controller.core.game_integration.community_loader import register_community_adapters
from wled_controller.core.mqtt.mqtt_service import MQTTService
from wled_controller.core.devices.mqtt_client import set_mqtt_service
from wled_controller.core.backup.auto_backup import AutoBackupEngine
@@ -97,6 +101,9 @@ sync_clock_manager = SyncClockManager(sync_clock_store)
weather_manager = WeatherManager(weather_source_store)
ha_store = HomeAssistantStore(db)
ha_manager = HomeAssistantManager(ha_store)
game_integration_store = GameIntegrationStore(db)
game_event_bus = GameEventBus()
register_community_adapters()
processor_manager = ProcessorManager(
ProcessorDependencies(
@@ -114,6 +121,7 @@ processor_manager = ProcessorManager(
weather_manager=weather_manager,
asset_store=asset_store,
ha_manager=ha_manager,
game_event_bus=game_event_bus,
)
)
@@ -212,6 +220,8 @@ async def lifespan(app: FastAPI):
asset_store=asset_store,
ha_store=ha_store,
ha_manager=ha_manager,
game_integration_store=game_integration_store,
game_event_bus=game_event_bus,
)
# Register devices in processor manager for health monitoring
@@ -15,4 +15,5 @@
@import './tutorials.css';
@import './graph-editor.css';
@import './appearance.css';
@import './game-integration.css';
@import './mobile.css';
@@ -27,7 +27,7 @@
padding: 0 4px;
}
/* Automation condition pills — constrain to card width */
/* Automation rule pills — constrain to card width */
[data-automation-id] .card-meta {
display: flex;
flex-wrap: wrap;
@@ -41,8 +41,8 @@
white-space: nowrap;
}
/* Automation condition editor rows */
.automation-condition-row {
/* Automation rule editor rows */
.automation-rule-row {
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 10px;
@@ -50,19 +50,19 @@
background: var(--bg-secondary, var(--bg-color));
}
.condition-header {
.rule-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.condition-type-label {
.rule-type-label {
font-weight: 600;
font-size: 0.9rem;
}
.condition-type-select {
.rule-type-select {
font-weight: 600;
font-size: 0.9rem;
padding: 2px 6px;
@@ -72,13 +72,13 @@
color: var(--text-color);
}
.condition-always-desc {
.rule-hint-desc {
display: block;
color: var(--text-muted);
font-size: 0.85rem;
}
.btn-remove-condition {
.btn-remove-rule {
background: none;
border: none;
color: var(--danger-color, #dc3545);
@@ -88,22 +88,22 @@
transition: opacity 0.15s;
}
.btn-remove-condition:hover {
.btn-remove-rule:hover {
opacity: 1;
}
.btn-remove-condition .icon {
.btn-remove-rule .icon {
width: 16px;
height: 16px;
}
.condition-fields {
.rule-fields {
display: flex;
flex-direction: column;
gap: 8px;
}
.condition-field label {
.rule-field label {
display: block;
font-size: 0.85rem;
margin-bottom: 3px;
@@ -202,8 +202,8 @@
}
.condition-field select,
.condition-field textarea {
.rule-field select,
.rule-field textarea {
width: 100%;
padding: 6px 8px;
border: 1px solid var(--border-color);
@@ -214,12 +214,12 @@
font-family: inherit;
}
.condition-apps {
.rule-apps {
resize: vertical;
min-height: 60px;
}
.condition-apps-header {
.rule-apps-header {
display: flex;
justify-content: space-between;
align-items: center;
@@ -0,0 +1,241 @@
/* ── Game Integration ── */
/* Status indicator badges */
.gi-status-active {
color: var(--success-color);
}
.gi-status-inactive {
color: var(--text-muted);
}
/* Mapping editor toolbar */
.gi-mapping-toolbar {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.gi-mapping-toolbar select {
flex: 0 0 auto;
max-width: 200px;
}
/* Mapping list */
.gi-mappings-list {
display: flex;
flex-direction: column;
gap: 8px;
}
/* Single mapping row — collapsible item (matches composite-layer-item) */
.gi-mapping-row {
display: flex;
flex-direction: column;
gap: 4px;
padding: 10px;
border: 1px solid var(--border-color);
border-radius: 6px;
background: var(--bg-secondary, var(--bg-color));
}
/* Header — always visible summary */
.gi-mapping-header {
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
user-select: none;
}
.gi-mapping-expand-btn {
font-size: 0.6rem;
color: var(--text-secondary);
transition: transform 0.15s ease;
flex-shrink: 0;
width: 12px;
text-align: center;
}
.gi-mapping-expanded .gi-mapping-expand-btn {
transform: rotate(90deg);
}
.gi-mapping-summary {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 8px;
overflow: hidden;
}
.gi-mapping-summary-event {
font-size: 0.85rem;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.gi-mapping-summary-effect {
font-size: 0.75rem;
color: var(--text-secondary);
background: var(--bg-color);
padding: 1px 6px;
border-radius: 3px;
white-space: nowrap;
flex-shrink: 0;
}
.gi-mapping-summary-color {
width: 14px;
height: 14px;
border-radius: 50%;
border: 1px solid var(--border-color);
flex-shrink: 0;
}
/* Collapsible body — CSS grid transition (matches composite-layer) */
.gi-mapping-body-wrapper {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 0.2s ease;
}
.gi-mapping-expanded .gi-mapping-body-wrapper {
grid-template-rows: 1fr;
}
.gi-mapping-body {
display: flex;
flex-direction: column;
gap: 8px;
padding-top: 0;
overflow: hidden;
min-height: 0;
transition: padding-top 0.2s ease;
font-size: 0.85rem;
}
.gi-mapping-expanded .gi-mapping-body {
padding-top: 8px;
}
/* Field rows inside body */
.gi-mapping-field-row {
display: flex;
align-items: center;
gap: 6px;
}
.gi-mapping-field-row label {
font-size: 0.85rem;
color: var(--text-muted);
min-width: 70px;
flex-shrink: 0;
}
.gi-mapping-field-row select,
.gi-mapping-field-row input[type="text"],
.gi-mapping-field-row input[type="number"] {
flex: 1;
min-width: 0;
padding: 6px 8px;
border: 1px solid var(--border-color);
border-radius: 4px;
background: var(--bg-color);
color: var(--text-color);
font-size: 0.9rem;
font-family: inherit;
}
.gi-mapping-field-row input[type="range"] {
flex: 1;
min-width: 0;
}
.gi-mapping-field-row input[type="color"] {
width: 40px;
height: 30px;
padding: 0;
border: 1px solid var(--border-color);
border-radius: 4px;
cursor: pointer;
}
/* Setup instructions pre block */
.gi-instructions-pre {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 12px;
font-family: var(--font-mono, monospace);
font-size: 0.85em;
white-space: pre-wrap;
word-break: break-all;
max-height: 200px;
overflow-y: auto;
}
/* Live event feed */
.gi-event-feed {
max-height: 200px;
overflow-y: auto;
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 8px;
background: var(--bg-secondary);
font-size: 0.85em;
}
.gi-event-waiting {
color: var(--text-muted);
text-align: center;
padding: 16px;
}
.gi-event-item {
display: flex;
gap: 8px;
padding: 3px 0;
border-bottom: 1px solid var(--border-color);
font-family: var(--font-mono, monospace);
font-size: 0.9em;
}
.gi-event-item:last-child {
border-bottom: none;
}
.gi-event-time {
color: var(--text-muted);
flex-shrink: 0;
}
.gi-event-type {
color: var(--primary-text-color);
font-weight: 600;
}
.gi-event-value {
color: var(--text-secondary);
}
/* Connection test panel */
.gi-test-panel {
margin-top: 8px;
padding: 12px;
border-radius: 6px;
border: 1px solid var(--border-color);
background: var(--bg-secondary);
}
.gi-test-waiting {
color: var(--warning-color);
}
.gi-test-success {
color: var(--success-color);
}
.gi-test-error {
color: var(--danger-color);
}
.gi-test-timeout {
color: var(--text-muted);
}
/* Responsive mapping rows */
@media (max-width: 768px) {
.gi-mapping-field-row {
flex-direction: column;
align-items: stretch;
}
.gi-mapping-field-row label {
min-width: unset;
}
}
+27 -2
View File
@@ -81,9 +81,17 @@ import {
} from './features/pattern-templates.ts';
import {
loadAutomations, switchAutomationTab, openAutomationEditor, closeAutomationEditorModal,
saveAutomationEditor, addAutomationCondition,
saveAutomationEditor, addAutomationRule,
toggleAutomationEnabled, cloneAutomation, deleteAutomation, copyWebhookUrl,
} from './features/automations.ts';
import {
showGameIntegrationEditor, saveGameIntegration, closeGameIntegrationModal,
cloneGameIntegration, deleteGameIntegration,
addGameMapping, removeGameMapping, onMappingPresetChange,
testGameConnection, showGameEventMonitor,
openSetupInstructions, closeSetupInstructions,
autoSetupGameIntegration,
} from './features/game-integration.ts';
import {
openScenePresetCapture, editScenePreset, saveScenePreset, closeScenePresetEditor,
activateScenePreset, cloneScenePreset, deleteScenePreset,
@@ -132,6 +140,7 @@ import {
testNotification,
showNotificationHistory, closeNotificationHistory, refreshNotificationHistory,
testColorStrip, testCSPT, closeTestCssSourceModal, applyCssTestSettings, fireCssTestNotification, fireCssTestNotificationLayer,
addCSSGameMapping, removeCSSGameMapping, onCSSGameMappingPresetChange,
} from './features/color-strips.ts';
// Layer 5: audio sources
@@ -369,7 +378,7 @@ Object.assign(window, {
openAutomationEditor,
closeAutomationEditorModal,
saveAutomationEditor,
addAutomationCondition,
addAutomationRule,
toggleAutomationEnabled,
cloneAutomation,
deleteAutomation,
@@ -385,6 +394,21 @@ Object.assign(window, {
deleteScenePreset,
addSceneTarget,
// game integration
showGameIntegrationEditor,
saveGameIntegration,
closeGameIntegrationModal,
cloneGameIntegration,
deleteGameIntegration,
addGameMapping,
removeGameMapping,
onMappingPresetChange,
testGameConnection,
showGameEventMonitor,
openSetupInstructions,
closeSetupInstructions,
autoSetupGameIntegration,
// device-discovery
onDeviceTypeChanged,
updateBaudFpsHint,
@@ -448,6 +472,7 @@ Object.assign(window, {
testNotification,
showNotificationHistory, closeNotificationHistory, refreshNotificationHistory,
testColorStrip, testCSPT, closeTestCssSourceModal, applyCssTestSettings, fireCssTestNotification, fireCssTestNotificationLayer,
addCSSGameMapping, removeCSSGameMapping, onCSSGameMappingPresetChange,
// audio sources
showAudioSourceModal,
@@ -99,3 +99,17 @@ export const droplets = '<path d="M7 16.3c2.2 0 4-1.83 4-4.05 0-1.16-.57-2.2
export const fan = '<path d="M10.827 16.379a6.082 6.082 0 0 1-8.618-7.002l5.412 1.45a6.082 6.082 0 0 1 7.002-8.618l-1.45 5.412a6.082 6.082 0 0 1 8.618 7.002l-5.412-1.45a6.082 6.082 0 0 1-7.002 8.618l1.45-5.412Z"/><path d="M12 12v.01"/>';
export const hardDrive = '<line x1="22" x2="2" y1="12" y2="12"/><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/><line x1="6" x2="6.01" y1="16" y2="16"/><line x1="10" x2="10.01" y1="16" y2="16"/>';
export const batteryFull = '<rect width="16" height="10" x="2" y="7" rx="2" ry="2"/><line x1="22" x2="22" y1="11" y2="13"/><line x1="6" x2="6" y1="11" y2="13"/><line x1="10" x2="10" y1="11" y2="13"/><line x1="14" x2="14" y1="11" y2="13"/>';
// Lucide: gamepad-2
export const gamepad2 = '<line x1="6" x2="10" y1="11" y2="11"/><line x1="8" x2="8" y1="9" y2="13"/><line x1="15" x2="15.01" y1="12" y2="12"/><line x1="18" x2="18.01" y1="10" y2="10"/><path d="M17.32 5H6.68a4 4 0 0 0-3.978 3.59c-.006.052-.01.101-.017.152C2.604 9.416 2 14.456 2 16a3 3 0 0 0 3 3c1 0 1.5-.5 2-1l1.414-1.414A2 2 0 0 1 9.828 16h4.344a2 2 0 0 1 1.414.586L17 18c.5.5 1 1 2 1a3 3 0 0 0 3-3c0-1.545-.604-6.584-.685-7.258-.007-.05-.011-.1-.017-.151A4 4 0 0 0 17.32 5z"/>';
// Lucide: crosshair
export const crosshair = '<circle cx="12" cy="12" r="10"/><line x1="22" x2="18" y1="12" y2="12"/><line x1="6" x2="2" y1="12" y2="12"/><line x1="12" x2="12" y1="6" y2="2"/><line x1="12" x2="12" y1="22" y2="18"/>';
// Lucide: swords
export const swords = '<polyline points="14.5 17.5 3 6 3 3 6 3 17.5 14.5"/><line x1="13" x2="19" y1="19" y2="13"/><line x1="16" x2="20" y1="16" y2="20"/><line x1="19" x2="21" y1="21" y2="19"/><polyline points="14.5 6.5 18 3 21 3 21 6 17.5 9.5"/><line x1="5" x2="9" y1="14" y2="18"/><line x1="7" x2="4" y1="17" y2="20"/><line x1="3" x2="5" y1="19" y2="21"/>';
// Lucide: shield
export const shield = '<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/>';
// Lucide: pickaxe (Minecraft-style)
export const pickaxe = '<path d="M14.531 12.469 6.619 20.38a1 1 0 1 1-3-3l7.912-7.912"/><path d="M15.686 4.314A12.5 12.5 0 0 0 5.461 2.958 1 1 0 0 0 5.58 4.71a22 22 0 0 1 6.318 3.393"/><path d="M17.7 3.7a1 1 0 0 0-1.4 0l-4.6 4.6a1 1 0 0 0 0 1.4l2.6 2.6a1 1 0 0 0 1.4 0l4.6-4.6a1 1 0 0 0 0-1.4z"/><path d="M19.686 8.314a12.501 12.501 0 0 1 1.356 10.225 1 1 0 0 1-1.751-.119 22 22 0 0 0-3.393-6.318"/>';
// Lucide: rocket
export const rocketIcon = '<path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z"/><path d="m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z"/><path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0"/><path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5"/>';
// Lucide: circle-dot (status indicator)
export const circleDot = '<circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="1"/>';
@@ -29,6 +29,7 @@ const _colorStripTypeIcons = {
weather: _svg(P.cloudSun),
processed: _svg(P.sparkles),
key_colors: _svg(P.palette),
game_event: _svg(P.gamepad2),
};
const _valueSourceTypeIcons = {
static: _svg(P.layoutDashboard), animated: _svg(P.refreshCw), audio: _svg(P.music),
@@ -39,6 +40,7 @@ const _valueSourceTypeIcons = {
ha_entity: _svg(P.home), gradient_map: _svg(P.rainbow),
css_extract: _svg(P.droplets),
system_metrics: _svg(P.cpu),
game_event: _svg(P.gamepad2),
};
const _audioSourceTypeIcons = { mono: _svg(P.mic), multichannel: _svg(P.volume2), band_extract: _svg(P.activity) };
const _deviceTypeIcons = {
@@ -333,6 +335,31 @@ export const ICON_ASSET = _svg(P.packageIcon);
export const ICON_HEART = _svg(P.heart);
export const ICON_GITHUB = _svg(P.github);
// ── Game integration icons ─────────────────────────────────
export const ICON_GAMEPAD = _svg(P.gamepad2);
export const ICON_CROSSHAIR = _svg(P.crosshair);
export const ICON_SWORDS = _svg(P.swords);
export const ICON_SHIELD = _svg(P.shield);
export const ICON_PICKAXE = _svg(P.pickaxe);
export const ICON_ROCKET_ICON = _svg(P.rocketIcon);
export const ICON_CIRCLE_DOT = _svg(P.circleDot);
/** Game adapter type → icon (fallback: gamepad) */
const _gameAdapterTypeIcons: Record<string, string> = {
cs2_gsi: _svg(P.crosshair),
valorant: _svg(P.crosshair),
lol_live: _svg(P.swords),
dota2_gsi: _svg(P.swords),
minecraft: _svg(P.pickaxe),
rocket_league: _svg(P.rocketIcon),
generic_webhook: _svg(P.globe),
};
export function getGameAdapterIcon(adapterType: string): string {
return _gameAdapterTypeIcons[adapterType] || _svg(P.gamepad2);
}
/** Asset type → icon (fallback: file) */
export function getAssetTypeIcon(assetType: string): string {
const map: Record<string, string> = {
@@ -13,6 +13,7 @@ import type {
SyncClock, WeatherSource, HomeAssistantSource, Asset, Automation, Display, FilterDef, EngineInfo,
CaptureTemplate, PostprocessingTemplate, AudioTemplate,
ColorStripProcessingTemplate, FilterInstance, KeyColorRectangle,
GameIntegration, GameAdapterInfo,
} from '../types.ts';
export let apiKey: string | null = null;
@@ -348,3 +349,19 @@ export const gradientsCache = new DataCache<GradientEntity[]>({
endpoint: '/gradients',
extractData: json => json.gradients || [],
});
// ── Game Integration caches ──
export let _cachedGameIntegrations: GameIntegration[] = [];
export const gameIntegrationsCache = new DataCache<GameIntegration[]>({
endpoint: '/game-integrations',
extractData: json => json.integrations || [],
});
gameIntegrationsCache.subscribe(v => { _cachedGameIntegrations = v; });
export let _cachedGameAdapters: GameAdapterInfo[] = [];
export const gameAdaptersCache = new DataCache<GameAdapterInfo[]>({
endpoint: '/game-adapters',
extractData: json => json.adapters || [],
});
gameAdaptersCache.subscribe(v => { _cachedGameAdapters = v; });
@@ -1,5 +1,5 @@
/**
* Automations — automation cards, editor, condition builder, process picker, scene selector.
* Automations — automation cards, editor, rule builder, process picker, scene selector.
*/
import { apiKey, _automationsLoading, set_automationsLoading, automationsCacheObj, scenePresetsCache, _cachedHASources } from '../core/state.ts';
@@ -22,30 +22,33 @@ import { TreeNav } from '../core/tree-nav.ts';
import { csScenes, createSceneCard, initScenePresetDelegation } from './scene-presets.ts';
import type { Automation } from '../types.ts';
// ── HA condition entity cache ──
let _haConditionEntities: any[] = [];
// ── HA rule entity cache ──
let _haRuleEntities: any[] = [];
async function _loadHAEntitiesForCondition(haSourceId: string, container: HTMLElement): Promise<void> {
if (!haSourceId) { _haConditionEntities = []; return; }
async function _loadHAEntitiesForRule(haSourceId: string, container: HTMLElement): Promise<void> {
if (!haSourceId) { _haRuleEntities = []; return; }
try {
const resp = await fetchWithAuth(`/home-assistant/sources/${haSourceId}/entities`);
if (!resp.ok) { _haConditionEntities = []; return; }
if (!resp.ok) { _haRuleEntities = []; return; }
const data = await resp.json();
_haConditionEntities = data.entities || [];
_haRuleEntities = data.entities || [];
} catch {
_haConditionEntities = [];
_haRuleEntities = [];
}
// Rebuild entity select options
const entitySelect = container.querySelector('.condition-ha-entity-id') as HTMLSelectElement;
const entitySelect = container.querySelector('.rule-ha-entity-id') as HTMLSelectElement;
if (entitySelect) {
const currentVal = entitySelect.value;
entitySelect.innerHTML = `<option value="">—</option>` +
_haConditionEntities.map((e: any) =>
_haRuleEntities.map((e: any) =>
`<option value="${e.entity_id}" ${e.entity_id === currentVal ? 'selected' : ''}>${escapeHtml(e.friendly_name || e.entity_id)}</option>`
).join('');
if (currentVal && !_haConditionEntities.some((e: any) => e.entity_id === currentVal)) {
if (currentVal && !_haRuleEntities.some((e: any) => e.entity_id === currentVal)) {
entitySelect.innerHTML += `<option value="${escapeHtml(currentVal)}" selected>${escapeHtml(currentVal)}</option>`;
}
// Refresh the EntitySelect wrapper so the trigger shows the friendly name
const es = (entitySelect as any)._entitySelect as EntitySelect | undefined;
if (es) es.refresh();
}
}
@@ -61,12 +64,12 @@ function _autoGenerateAutomationName() {
const sceneSel = document.getElementById('automation-scene-id') as HTMLSelectElement | null;
const sceneName = sceneSel?.selectedOptions[0]?.textContent?.trim() || '';
const logic = (document.getElementById('automation-editor-logic') as HTMLSelectElement).value;
const condCount = document.querySelectorAll('#automation-conditions-list .condition-row').length;
const ruleCount = document.querySelectorAll('#automation-rules-list .rule-row').length;
let name = '';
if (sceneName) name = sceneName;
if (condCount > 0) {
if (ruleCount > 0) {
const logicLabel = logic === 'and' ? 'AND' : 'OR';
const suffix = `${condCount} ${logicLabel}`;
const suffix = `${ruleCount} ${logicLabel}`;
name = name ? `${name} · ${suffix}` : suffix;
}
(document.getElementById('automation-editor-name') as HTMLInputElement).value = name || t('automations.add');
@@ -84,7 +87,7 @@ class AutomationEditorModal extends Modal {
name: (document.getElementById('automation-editor-name') as HTMLInputElement).value,
enabled: (document.getElementById('automation-editor-enabled') as HTMLInputElement).checked.toString(),
logic: (document.getElementById('automation-editor-logic') as HTMLSelectElement).value,
conditions: JSON.stringify(getAutomationEditorConditions()),
rules: JSON.stringify(getAutomationEditorRules()),
scenePresetId: (document.getElementById('automation-scene-id') as HTMLSelectElement).value,
deactivationMode: (document.getElementById('automation-deactivation-mode') as HTMLSelectElement).value,
deactivationScenePresetId: (document.getElementById('automation-fallback-scene-id') as HTMLSelectElement).value,
@@ -162,17 +165,17 @@ export function switchAutomationTab(tabKey: string) {
const _icon = (d: any) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
let _conditionLogicIconSelect: any = null;
let _ruleLogicIconSelect: any = null;
function _ensureConditionLogicIconSelect() {
function _ensureRuleLogicIconSelect() {
const sel = document.getElementById('automation-editor-logic');
if (!sel) return;
const items = [
{ value: 'or', icon: _icon(P.zap), label: t('automations.condition_logic.or'), desc: t('automations.condition_logic.or.desc') },
{ value: 'and', icon: _icon(P.link), label: t('automations.condition_logic.and'), desc: t('automations.condition_logic.and.desc') },
{ value: 'or', icon: _icon(P.zap), label: t('automations.rule_logic.or'), desc: t('automations.rule_logic.or.desc') },
{ value: 'and', icon: _icon(P.link), label: t('automations.rule_logic.and'), desc: t('automations.rule_logic.and.desc') },
];
if (_conditionLogicIconSelect) { _conditionLogicIconSelect.updateItems(items); return; }
_conditionLogicIconSelect = new IconSelect({ target: sel, items, columns: 2 } as any);
if (_ruleLogicIconSelect) { _ruleLogicIconSelect.updateItems(items); return; }
_ruleLogicIconSelect = new IconSelect({ target: sel, items, columns: 2 } as any);
}
// Re-render automations when language changes (only if tab is active)
@@ -255,44 +258,43 @@ function renderAutomations(automations: any, sceneMap: any) {
}
}
type ConditionPillRenderer = (c: any) => string;
type RulePillRenderer = (c: any) => string;
const CONDITION_PILL_RENDERERS: Record<string, ConditionPillRenderer> = {
always: (c) => `<span class="stream-card-prop">${ICON_OK} ${t('automations.condition.always')}</span>`,
startup: (c) => `<span class="stream-card-prop">${ICON_START} ${t('automations.condition.startup')}</span>`,
const RULE_PILL_RENDERERS: Record<string, RulePillRenderer> = {
startup: (c) => `<span class="stream-card-prop">${ICON_START} ${t('automations.rule.startup')}</span>`,
application: (c) => {
const apps = (c.apps || []).join(', ');
const matchLabel = t('automations.condition.application.match_type.' + (c.match_type || 'running'));
return `<span class="stream-card-prop stream-card-prop-full">${t('automations.condition.application')}: ${apps} (${matchLabel})</span>`;
const matchLabel = t('automations.rule.application.match_type.' + (c.match_type || 'running'));
return `<span class="stream-card-prop stream-card-prop-full">${t('automations.rule.application')}: ${apps} (${matchLabel})</span>`;
},
time_of_day: (c) => `<span class="stream-card-prop">${ICON_CLOCK} ${t('automations.condition.time_of_day')}: ${c.start_time || '00:00'} ${c.end_time || '23:59'}</span>`,
time_of_day: (c) => `<span class="stream-card-prop">${ICON_CLOCK} ${t('automations.rule.time_of_day')}: ${c.start_time || '00:00'} ${c.end_time || '23:59'}</span>`,
system_idle: (c) => {
const mode = c.when_idle !== false ? t('automations.condition.system_idle.when_idle') : t('automations.condition.system_idle.when_active');
const mode = c.when_idle !== false ? t('automations.rule.system_idle.when_idle') : t('automations.rule.system_idle.when_active');
return `<span class="stream-card-prop">${ICON_TIMER} ${c.idle_minutes || 5}m (${mode})</span>`;
},
display_state: (c) => {
const stateLabel = t('automations.condition.display_state.' + (c.state || 'on'));
return `<span class="stream-card-prop">${ICON_MONITOR} ${t('automations.condition.display_state')}: ${stateLabel}</span>`;
const stateLabel = t('automations.rule.display_state.' + (c.state || 'on'));
return `<span class="stream-card-prop">${ICON_MONITOR} ${t('automations.rule.display_state')}: ${stateLabel}</span>`;
},
mqtt: (c) => `<span class="stream-card-prop stream-card-prop-full">${ICON_RADIO} ${t('automations.condition.mqtt')}: ${escapeHtml(c.topic || '')} = ${escapeHtml(c.payload || '*')}</span>`,
webhook: (c) => `<span class="stream-card-prop">${ICON_WEB} ${t('automations.condition.webhook')}</span>`,
home_assistant: (c) => `<span class="stream-card-prop stream-card-prop-full">${_icon(P.home)} ${t('automations.condition.home_assistant')}: ${escapeHtml(c.entity_id || '')} = ${escapeHtml(c.state || '*')}</span>`,
mqtt: (c) => `<span class="stream-card-prop stream-card-prop-full">${ICON_RADIO} ${t('automations.rule.mqtt')}: ${escapeHtml(c.topic || '')} = ${escapeHtml(c.payload || '*')}</span>`,
webhook: (c) => `<span class="stream-card-prop">${ICON_WEB} ${t('automations.rule.webhook')}</span>`,
home_assistant: (c) => `<span class="stream-card-prop stream-card-prop-full">${_icon(P.home)} ${t('automations.rule.home_assistant')}: ${escapeHtml(c.entity_id || '')} = ${escapeHtml(c.state || '*')}</span>`,
};
function createAutomationCard(automation: Automation, sceneMap = new Map()) {
const statusClass = !automation.enabled ? 'disabled' : automation.is_active ? 'active' : 'inactive';
const statusText = !automation.enabled ? t('automations.status.disabled') : automation.is_active ? t('automations.status.active') : t('automations.status.inactive');
let condPills = '';
if (automation.conditions.length === 0) {
condPills = `<span class="stream-card-prop">${t('automations.conditions.empty')}</span>`;
let rulePills = '';
if (automation.rules.length === 0) {
rulePills = `<span class="stream-card-prop">${t('automations.rules.empty')}</span>`;
} else {
const parts = automation.conditions.map(c => {
const renderer = CONDITION_PILL_RENDERERS[c.condition_type];
return renderer ? renderer(c) : `<span class="stream-card-prop">${c.condition_type}</span>`;
const parts = automation.rules.map(c => {
const renderer = RULE_PILL_RENDERERS[c.rule_type];
return renderer ? renderer(c) : `<span class="stream-card-prop">${c.rule_type}</span>`;
});
const logicLabel = automation.condition_logic === 'and' ? t('automations.logic.and') : t('automations.logic.or');
condPills = parts.join(`<span class="automation-logic-label">${logicLabel}</span>`);
const logicLabel = automation.rule_logic === 'and' ? t('automations.logic.and') : t('automations.logic.or');
rulePills = parts.join(`<span class="automation-logic-label">${logicLabel}</span>`);
}
// Scene info
@@ -334,7 +336,7 @@ function createAutomationCard(automation: Automation, sceneMap = new Map()) {
</div>
</div>
<div class="card-subtitle">
<span class="card-meta">${condPills}</span>
<span class="card-meta">${rulePills}</span>
<span class="card-meta${scene ? ' stream-card-link' : ''}"${scene ? ` onclick="event.stopPropagation(); navigateToCard('automations',null,'scenes','data-scene-id','${automation.scene_preset_id}')"` : ''}>${ICON_SCENE} <span style="color:${sceneColor}">&#x25CF;</span> ${sceneName}</span>
${deactivationMeta}
</div>
@@ -355,13 +357,13 @@ export async function openAutomationEditor(automationId?: any, cloneData?: any)
const nameInput = document.getElementById('automation-editor-name') as HTMLInputElement;
const enabledInput = document.getElementById('automation-editor-enabled') as HTMLInputElement;
const logicSelect = document.getElementById('automation-editor-logic') as HTMLSelectElement;
const condList = document.getElementById('automation-conditions-list');
const ruleList = document.getElementById('automation-rules-list');
const errorEl = document.getElementById('automation-editor-error') as HTMLElement;
errorEl.style.display = 'none';
condList!.innerHTML = '';
ruleList!.innerHTML = '';
_ensureConditionLogicIconSelect();
_ensureRuleLogicIconSelect();
_ensureDeactivationModeIconSelect();
// Fetch scenes for selector
@@ -386,11 +388,11 @@ export async function openAutomationEditor(automationId?: any, cloneData?: any)
idInput.value = automation.id;
nameInput.value = automation.name;
enabledInput.checked = automation.enabled;
logicSelect.value = automation.condition_logic;
if (_conditionLogicIconSelect) _conditionLogicIconSelect.setValue(automation.condition_logic);
logicSelect.value = automation.rule_logic;
if (_ruleLogicIconSelect) _ruleLogicIconSelect.setValue(automation.rule_logic);
for (const c of automation.conditions) {
addAutomationConditionRow(c);
for (const c of automation.rules) {
addAutomationRuleRow(c);
}
// Scene selector
@@ -413,14 +415,14 @@ export async function openAutomationEditor(automationId?: any, cloneData?: any)
idInput.value = '';
nameInput.value = (cloneData.name || '') + ' (Copy)';
enabledInput.checked = cloneData.enabled !== false;
logicSelect.value = cloneData.condition_logic || 'or';
if (_conditionLogicIconSelect) _conditionLogicIconSelect.setValue(cloneData.condition_logic || 'or');
logicSelect.value = cloneData.rule_logic || 'or';
if (_ruleLogicIconSelect) _ruleLogicIconSelect.setValue(cloneData.rule_logic || 'or');
// Clone conditions (strip webhook tokens — they must be unique)
for (const c of (cloneData.conditions || [])) {
// Clone rules (strip webhook tokens — they must be unique)
for (const c of (cloneData.rules || [])) {
const clonedCond = { ...c };
if (clonedCond.condition_type === 'webhook') delete clonedCond.token;
addAutomationConditionRow(clonedCond);
if (clonedCond.rule_type === 'webhook') delete clonedCond.token;
addAutomationRuleRow(clonedCond);
}
_initSceneSelector('automation-scene-id', cloneData.scene_preset_id);
@@ -437,7 +439,7 @@ export async function openAutomationEditor(automationId?: any, cloneData?: any)
nameInput.value = '';
enabledInput.checked = true;
logicSelect.value = 'or';
if (_conditionLogicIconSelect) _conditionLogicIconSelect.setValue('or');
if (_ruleLogicIconSelect) _ruleLogicIconSelect.setValue('or');
_initSceneSelector('automation-scene-id', null);
_initSceneSelector('automation-fallback-scene-id', null);
}
@@ -539,14 +541,14 @@ function _ensureDeactivationModeIconSelect() {
// ===== Condition editor =====
export function addAutomationCondition() {
addAutomationConditionRow({ condition_type: 'application', apps: [], match_type: 'running' });
export function addAutomationRule() {
addAutomationRuleRow({ rule_type: 'application', apps: [], match_type: 'running' });
_autoGenerateAutomationName();
}
const CONDITION_TYPE_KEYS = ['always', 'startup', 'application', 'time_of_day', 'system_idle', 'display_state', 'mqtt', 'webhook', 'home_assistant'];
const CONDITION_TYPE_ICONS = {
always: P.refreshCw, startup: P.power, application: P.smartphone,
const RULE_TYPE_KEYS = ['startup', 'application', 'time_of_day', 'system_idle', 'display_state', 'mqtt', 'webhook', 'home_assistant'];
const RULE_TYPE_ICONS = {
startup: P.power, application: P.smartphone,
time_of_day: P.clock, system_idle: P.moon, display_state: P.monitor,
mqtt: P.radio, webhook: P.globe, home_assistant: P.home,
};
@@ -560,17 +562,17 @@ function _buildMatchTypeItems() {
return MATCH_TYPE_KEYS.map(k => ({
value: k,
icon: _icon(MATCH_TYPE_ICONS[k]),
label: t(`automations.condition.application.match_type.${k}`),
desc: t(`automations.condition.application.match_type.${k}.desc`),
label: t(`automations.rule.application.match_type.${k}`),
desc: t(`automations.rule.application.match_type.${k}.desc`),
}));
}
function _buildConditionTypeItems() {
return CONDITION_TYPE_KEYS.map(k => ({
function _buildRuleTypeItems() {
return RULE_TYPE_KEYS.map(k => ({
value: k,
icon: _icon(CONDITION_TYPE_ICONS[k]),
label: t(`automations.condition.${k}`),
desc: t(`automations.condition.${k}.desc`),
icon: _icon(RULE_TYPE_ICONS[k]),
label: t(`automations.rule.${k}`),
desc: t(`automations.rule.${k}.desc`),
}));
}
@@ -580,8 +582,8 @@ function _wireTimeRangePicker(container: HTMLElement) {
const startM = container.querySelector('.tr-start-m') as HTMLInputElement;
const endH = container.querySelector('.tr-end-h') as HTMLInputElement;
const endM = container.querySelector('.tr-end-m') as HTMLInputElement;
const hiddenStart = container.querySelector('.condition-start-time') as HTMLInputElement;
const hiddenEnd = container.querySelector('.condition-end-time') as HTMLInputElement;
const hiddenStart = container.querySelector('.rule-start-time') as HTMLInputElement;
const hiddenEnd = container.querySelector('.rule-end-time') as HTMLInputElement;
if (!startH || !startM || !endH || !endM) return;
const pad = (n: number) => String(n).padStart(2, '0');
@@ -628,39 +630,35 @@ function _wireTimeRangePicker(container: HTMLElement) {
sync();
}
function addAutomationConditionRow(condition: any) {
const list = document.getElementById('automation-conditions-list');
function addAutomationRuleRow(rule: any) {
const list = document.getElementById('automation-rules-list');
const row = document.createElement('div');
row.className = 'automation-condition-row';
const condType = condition.condition_type || 'application';
row.className = 'automation-rule-row';
const ruleType = rule.rule_type || 'application';
row.innerHTML = `
<div class="condition-header">
<select class="condition-type-select">
${CONDITION_TYPE_KEYS.map(k => `<option value="${k}" ${condType === k ? 'selected' : ''}>${t('automations.condition.' + k)}</option>`).join('')}
<div class="rule-header">
<select class="rule-type-select">
${RULE_TYPE_KEYS.map(k => `<option value="${k}" ${ruleType === k ? 'selected' : ''}>${t('automations.rule.' + k)}</option>`).join('')}
</select>
<button type="button" class="btn-remove-condition" onclick="this.closest('.automation-condition-row').remove(); if(window._autoGenerateAutomationName) window._autoGenerateAutomationName();" title="Remove">${ICON_TRASH}</button>
<button type="button" class="btn-remove-rule" onclick="this.closest('.automation-rule-row').remove(); if(window._autoGenerateAutomationName) window._autoGenerateAutomationName();" title="Remove">${ICON_TRASH}</button>
</div>
<div class="condition-fields-container"></div>
<div class="rule-fields-container"></div>
`;
const typeSelect = row.querySelector('.condition-type-select') as HTMLSelectElement;
const container = row.querySelector('.condition-fields-container') as HTMLElement;
const typeSelect = row.querySelector('.rule-type-select') as HTMLSelectElement;
const container = row.querySelector('.rule-fields-container') as HTMLElement;
// Attach IconSelect to the condition type dropdown
const condIconSelect = new IconSelect({
// Attach IconSelect to the rule type dropdown
const ruleIconSelect = new IconSelect({
target: typeSelect,
items: _buildConditionTypeItems(),
items: _buildRuleTypeItems(),
columns: 4,
} as any);
function renderFields(type: any, data: any) {
if (type === 'always') {
container.innerHTML = `<small class="condition-always-desc">${t('automations.condition.always.hint')}</small>`;
return;
}
if (type === 'startup') {
container.innerHTML = `<small class="condition-always-desc">${t('automations.condition.startup.hint')}</small>`;
container.innerHTML = `<small class="rule-hint-desc">${t('automations.rule.startup.hint')}</small>`;
return;
}
if (type === 'time_of_day') {
@@ -670,12 +668,12 @@ function addAutomationConditionRow(condition: any) {
const [eh, em] = endTime.split(':').map(Number);
const pad = (n: number) => String(n).padStart(2, '0');
container.innerHTML = `
<div class="condition-fields">
<input type="hidden" class="condition-start-time" value="${startTime}">
<input type="hidden" class="condition-end-time" value="${endTime}">
<div class="rule-fields">
<input type="hidden" class="rule-start-time" value="${startTime}">
<input type="hidden" class="rule-end-time" value="${endTime}">
<div class="time-range-picker">
<div class="time-range-slot">
<span class="time-range-label">${t('automations.condition.time_of_day.start_time')}</span>
<span class="time-range-label">${t('automations.rule.time_of_day.start_time')}</span>
<div class="time-range-input-wrap">
<input type="number" class="tr-start-h" min="0" max="23" value="${sh}" data-role="hour">
<span class="time-range-colon">:</span>
@@ -684,7 +682,7 @@ function addAutomationConditionRow(condition: any) {
</div>
<div class="time-range-arrow">→</div>
<div class="time-range-slot">
<span class="time-range-label">${t('automations.condition.time_of_day.end_time')}</span>
<span class="time-range-label">${t('automations.rule.time_of_day.end_time')}</span>
<div class="time-range-input-wrap">
<input type="number" class="tr-end-h" min="0" max="23" value="${eh}" data-role="hour">
<span class="time-range-colon">:</span>
@@ -692,7 +690,7 @@ function addAutomationConditionRow(condition: any) {
</div>
</div>
</div>
<small class="condition-always-desc">${t('automations.condition.time_of_day.overnight_hint')}</small>
<small class="rule-hint-desc">${t('automations.rule.time_of_day.overnight_hint')}</small>
</div>`;
_wireTimeRangePicker(container);
return;
@@ -701,16 +699,16 @@ function addAutomationConditionRow(condition: any) {
const idleMinutes = data.idle_minutes ?? 5;
const whenIdle = data.when_idle ?? true;
container.innerHTML = `
<div class="condition-fields">
<div class="condition-field">
<label>${t('automations.condition.system_idle.idle_minutes')}</label>
<input type="number" class="condition-idle-minutes" min="1" max="999" value="${idleMinutes}">
<div class="rule-fields">
<div class="rule-field">
<label>${t('automations.rule.system_idle.idle_minutes')}</label>
<input type="number" class="rule-idle-minutes" min="1" max="999" value="${idleMinutes}">
</div>
<div class="condition-field">
<label>${t('automations.condition.system_idle.mode')}</label>
<select class="condition-when-idle">
<option value="true" ${whenIdle ? 'selected' : ''}>${t('automations.condition.system_idle.when_idle')}</option>
<option value="false" ${!whenIdle ? 'selected' : ''}>${t('automations.condition.system_idle.when_active')}</option>
<div class="rule-field">
<label>${t('automations.rule.system_idle.mode')}</label>
<select class="rule-when-idle">
<option value="true" ${whenIdle ? 'selected' : ''}>${t('automations.rule.system_idle.when_idle')}</option>
<option value="false" ${!whenIdle ? 'selected' : ''}>${t('automations.rule.system_idle.when_active')}</option>
</select>
</div>
</div>`;
@@ -719,12 +717,12 @@ function addAutomationConditionRow(condition: any) {
if (type === 'display_state') {
const dState = data.state || 'on';
container.innerHTML = `
<div class="condition-fields">
<div class="condition-field">
<label>${t('automations.condition.display_state.state')}</label>
<select class="condition-display-state">
<option value="on" ${dState === 'on' ? 'selected' : ''}>${t('automations.condition.display_state.on')}</option>
<option value="off" ${dState === 'off' ? 'selected' : ''}>${t('automations.condition.display_state.off')}</option>
<div class="rule-fields">
<div class="rule-field">
<label>${t('automations.rule.display_state.state')}</label>
<select class="rule-display-state">
<option value="on" ${dState === 'on' ? 'selected' : ''}>${t('automations.rule.display_state.on')}</option>
<option value="off" ${dState === 'off' ? 'selected' : ''}>${t('automations.rule.display_state.off')}</option>
</select>
</div>
</div>`;
@@ -735,21 +733,21 @@ function addAutomationConditionRow(condition: any) {
const payload = data.payload || '';
const matchMode = data.match_mode || 'exact';
container.innerHTML = `
<div class="condition-fields">
<div class="condition-field">
<label>${t('automations.condition.mqtt.topic')}</label>
<input type="text" class="condition-mqtt-topic" value="${escapeHtml(topic)}" placeholder="home/status/power">
<div class="rule-fields">
<div class="rule-field">
<label>${t('automations.rule.mqtt.topic')}</label>
<input type="text" class="rule-mqtt-topic" value="${escapeHtml(topic)}" placeholder="home/status/power">
</div>
<div class="condition-field">
<label>${t('automations.condition.mqtt.payload')}</label>
<input type="text" class="condition-mqtt-payload" value="${escapeHtml(payload)}" placeholder="ON">
<div class="rule-field">
<label>${t('automations.rule.mqtt.payload')}</label>
<input type="text" class="rule-mqtt-payload" value="${escapeHtml(payload)}" placeholder="ON">
</div>
<div class="condition-field">
<label>${t('automations.condition.mqtt.match_mode')}</label>
<select class="condition-mqtt-match-mode">
<option value="exact" ${matchMode === 'exact' ? 'selected' : ''}>${t('automations.condition.mqtt.match_mode.exact')}</option>
<option value="contains" ${matchMode === 'contains' ? 'selected' : ''}>${t('automations.condition.mqtt.match_mode.contains')}</option>
<option value="regex" ${matchMode === 'regex' ? 'selected' : ''}>${t('automations.condition.mqtt.match_mode.regex')}</option>
<div class="rule-field">
<label>${t('automations.rule.mqtt.match_mode')}</label>
<select class="rule-mqtt-match-mode">
<option value="exact" ${matchMode === 'exact' ? 'selected' : ''}>${t('automations.rule.mqtt.match_mode.exact')}</option>
<option value="contains" ${matchMode === 'contains' ? 'selected' : ''}>${t('automations.rule.mqtt.match_mode.contains')}</option>
<option value="regex" ${matchMode === 'regex' ? 'selected' : ''}>${t('automations.rule.mqtt.match_mode.regex')}</option>
</select>
</div>
</div>`;
@@ -764,37 +762,37 @@ function addAutomationConditionRow(condition: any) {
`<option value="${s.id}" ${s.id === haSourceId ? 'selected' : ''}>${escapeHtml(s.name)}</option>`
).join('');
container.innerHTML = `
<div class="condition-fields">
<small class="condition-always-desc">${t('automations.condition.home_assistant.hint')}</small>
<div class="condition-field">
<label>${t('automations.condition.home_assistant.ha_source')}</label>
<select class="condition-ha-source-id">
<div class="rule-fields">
<small class="rule-hint-desc">${t('automations.rule.home_assistant.hint')}</small>
<div class="rule-field">
<label>${t('automations.rule.home_assistant.ha_source')}</label>
<select class="rule-ha-source-id">
<option value="">—</option>
${haOptions}
</select>
</div>
<div class="condition-field">
<label>${t('automations.condition.home_assistant.entity_id')}</label>
<select class="condition-ha-entity-id">
<div class="rule-field">
<label>${t('automations.rule.home_assistant.entity_id')}</label>
<select class="rule-ha-entity-id">
${entityId ? `<option value="${escapeHtml(entityId)}" selected>${escapeHtml(entityId)}</option>` : '<option value="">—</option>'}
</select>
</div>
<div class="condition-field">
<label>${t('automations.condition.home_assistant.state')}</label>
<input type="text" class="condition-ha-state" value="${escapeHtml(haState)}" placeholder="on">
<div class="rule-field">
<label>${t('automations.rule.home_assistant.state')}</label>
<input type="text" class="rule-ha-state" value="${escapeHtml(haState)}" placeholder="on">
</div>
<div class="condition-field">
<label>${t('automations.condition.home_assistant.match_mode')}</label>
<select class="condition-ha-match-mode">
<option value="exact" ${matchMode === 'exact' ? 'selected' : ''}>${t('automations.condition.mqtt.match_mode.exact')}</option>
<option value="contains" ${matchMode === 'contains' ? 'selected' : ''}>${t('automations.condition.mqtt.match_mode.contains')}</option>
<option value="regex" ${matchMode === 'regex' ? 'selected' : ''}>${t('automations.condition.mqtt.match_mode.regex')}</option>
<div class="rule-field">
<label>${t('automations.rule.home_assistant.match_mode')}</label>
<select class="rule-ha-match-mode">
<option value="exact" ${matchMode === 'exact' ? 'selected' : ''}>${t('automations.rule.mqtt.match_mode.exact')}</option>
<option value="contains" ${matchMode === 'contains' ? 'selected' : ''}>${t('automations.rule.mqtt.match_mode.contains')}</option>
<option value="regex" ${matchMode === 'regex' ? 'selected' : ''}>${t('automations.rule.mqtt.match_mode.regex')}</option>
</select>
</div>
</div>`;
// Wire HA source EntitySelect
const haSrcSelect = container.querySelector('.condition-ha-source-id') as HTMLSelectElement;
const haSrcSelect = container.querySelector('.rule-ha-source-id') as HTMLSelectElement;
new EntitySelect({
target: haSrcSelect,
getItems: () => _cachedHASources.map((s: any) => ({
@@ -802,34 +800,36 @@ function addAutomationConditionRow(condition: any) {
desc: s.connected ? t('ha_source.connected') : t('ha_source.disconnected'),
})),
placeholder: t('palette.search'),
onChange: (newId: string) => _loadHAEntitiesForCondition(newId, container),
onChange: (newId: string) => _loadHAEntitiesForRule(newId, container),
});
// Wire entity EntitySelect
const entitySelect = container.querySelector('.condition-ha-entity-id') as HTMLSelectElement;
const entitySelect = container.querySelector('.rule-ha-entity-id') as HTMLSelectElement;
const entityES = new EntitySelect({
target: entitySelect,
getItems: () => _haConditionEntities.map((e: any) => ({
getItems: () => _haRuleEntities.map((e: any) => ({
value: e.entity_id, label: e.friendly_name || e.entity_id,
icon: getHAEntityIcon(e), desc: e.state || '',
})),
placeholder: t('ha_light.mapping.search_entity'),
});
// Store ref so _loadHAEntitiesForRule can refresh the trigger display
(entitySelect as any)._entitySelect = entityES;
// Wire match mode IconSelect
const matchSelect = container.querySelector('.condition-ha-match-mode') as HTMLSelectElement;
const matchSelect = container.querySelector('.rule-ha-match-mode') as HTMLSelectElement;
new IconSelect({
target: matchSelect,
items: [
{ value: 'exact', icon: _icon(P.check), label: t('automations.condition.mqtt.match_mode.exact'), desc: t('automations.condition.ha.match_mode.exact.desc') },
{ value: 'contains', icon: _icon(P.search), label: t('automations.condition.mqtt.match_mode.contains'), desc: t('automations.condition.ha.match_mode.contains.desc') },
{ value: 'regex', icon: _icon(P.code), label: t('automations.condition.mqtt.match_mode.regex'), desc: t('automations.condition.ha.match_mode.regex.desc') },
{ value: 'exact', icon: _icon(P.check), label: t('automations.rule.mqtt.match_mode.exact'), desc: t('automations.rule.ha.match_mode.exact.desc') },
{ value: 'contains', icon: _icon(P.search), label: t('automations.rule.mqtt.match_mode.contains'), desc: t('automations.rule.ha.match_mode.contains.desc') },
{ value: 'regex', icon: _icon(P.code), label: t('automations.rule.mqtt.match_mode.regex'), desc: t('automations.rule.ha.match_mode.regex.desc') },
],
columns: 1,
});
// Load entities if source is already selected
if (haSourceId) _loadHAEntitiesForCondition(haSourceId, container);
if (haSourceId) _loadHAEntitiesForRule(haSourceId, container);
return;
}
@@ -837,22 +837,22 @@ function addAutomationConditionRow(condition: any) {
if (data.token) {
const webhookUrl = getBaseOrigin() + '/api/v1/webhooks/' + data.token;
container.innerHTML = `
<div class="condition-fields">
<small class="condition-always-desc">${t('automations.condition.webhook.hint')}</small>
<div class="condition-field">
<label>${t('automations.condition.webhook.url')}</label>
<div class="rule-fields">
<small class="rule-hint-desc">${t('automations.rule.webhook.hint')}</small>
<div class="rule-field">
<label>${t('automations.rule.webhook.url')}</label>
<div class="webhook-url-row">
<input type="text" class="condition-webhook-url" value="${escapeHtml(webhookUrl)}" readonly>
<button type="button" class="btn btn-secondary btn-webhook-copy" onclick="copyWebhookUrl(this)">${t('automations.condition.webhook.copy')}</button>
<input type="text" class="rule-webhook-url" value="${escapeHtml(webhookUrl)}" readonly>
<button type="button" class="btn btn-secondary btn-webhook-copy" onclick="copyWebhookUrl(this)">${t('automations.rule.webhook.copy')}</button>
</div>
</div>
<input type="hidden" class="condition-webhook-token" value="${escapeHtml(data.token)}">
<input type="hidden" class="rule-webhook-token" value="${escapeHtml(data.token)}">
</div>`;
} else {
container.innerHTML = `
<div class="condition-fields">
<small class="condition-always-desc">${t('automations.condition.webhook.hint')}</small>
<p class="webhook-save-hint">${t('automations.condition.webhook.save_first')}</p>
<div class="rule-fields">
<small class="rule-hint-desc">${t('automations.rule.webhook.hint')}</small>
<p class="webhook-save-hint">${t('automations.rule.webhook.save_first')}</p>
</div>`;
}
return;
@@ -860,30 +860,30 @@ function addAutomationConditionRow(condition: any) {
const appsValue = (data.apps || []).join('\n');
const matchType = data.match_type || 'running';
container.innerHTML = `
<div class="condition-fields">
<div class="condition-field">
<label>${t('automations.condition.application.match_type')}</label>
<select class="condition-match-type">
<option value="running" ${matchType === 'running' ? 'selected' : ''}>${t('automations.condition.application.match_type.running')}</option>
<option value="topmost" ${matchType === 'topmost' ? 'selected' : ''}>${t('automations.condition.application.match_type.topmost')}</option>
<option value="topmost_fullscreen" ${matchType === 'topmost_fullscreen' ? 'selected' : ''}>${t('automations.condition.application.match_type.topmost_fullscreen')}</option>
<option value="fullscreen" ${matchType === 'fullscreen' ? 'selected' : ''}>${t('automations.condition.application.match_type.fullscreen')}</option>
<div class="rule-fields">
<div class="rule-field">
<label>${t('automations.rule.application.match_type')}</label>
<select class="rule-match-type">
<option value="running" ${matchType === 'running' ? 'selected' : ''}>${t('automations.rule.application.match_type.running')}</option>
<option value="topmost" ${matchType === 'topmost' ? 'selected' : ''}>${t('automations.rule.application.match_type.topmost')}</option>
<option value="topmost_fullscreen" ${matchType === 'topmost_fullscreen' ? 'selected' : ''}>${t('automations.rule.application.match_type.topmost_fullscreen')}</option>
<option value="fullscreen" ${matchType === 'fullscreen' ? 'selected' : ''}>${t('automations.rule.application.match_type.fullscreen')}</option>
</select>
</div>
<div class="condition-field">
<div class="condition-apps-header">
<label>${t('automations.condition.application.apps')}</label>
<button type="button" class="btn btn-icon btn-secondary btn-browse-apps" title="${t('automations.condition.application.browse')}">${ICON_SEARCH}</button>
<div class="rule-field">
<div class="rule-apps-header">
<label>${t('automations.rule.application.apps')}</label>
<button type="button" class="btn btn-icon btn-secondary btn-browse-apps" title="${t('automations.rule.application.browse')}">${ICON_SEARCH}</button>
</div>
<textarea class="condition-apps" rows="3" placeholder="firefox.exe&#10;chrome.exe">${escapeHtml(appsValue)}</textarea>
<textarea class="rule-apps" rows="3" placeholder="firefox.exe&#10;chrome.exe">${escapeHtml(appsValue)}</textarea>
</div>
</div>
`;
const textarea = container.querySelector('.condition-apps') as HTMLTextAreaElement;
const textarea = container.querySelector('.rule-apps') as HTMLTextAreaElement;
attachProcessPicker(container, textarea);
// Attach IconSelect to match type
const matchSel = container.querySelector('.condition-match-type');
const matchSel = container.querySelector('.rule-match-type');
if (matchSel) {
new IconSelect({
target: matchSel,
@@ -893,7 +893,7 @@ function addAutomationConditionRow(condition: any) {
}
}
renderFields(condType, condition);
renderFields(ruleType, rule);
typeSelect.addEventListener('change', () => {
renderFields(typeSelect.value, {});
});
@@ -903,61 +903,59 @@ function addAutomationConditionRow(condition: any) {
function getAutomationEditorConditions() {
const rows = document.querySelectorAll('#automation-conditions-list .automation-condition-row');
const conditions: any[] = [];
function getAutomationEditorRules() {
const rows = document.querySelectorAll('#automation-rules-list .automation-rule-row');
const rules: any[] = [];
rows.forEach(row => {
const typeSelect = row.querySelector('.condition-type-select') as HTMLSelectElement;
const condType = typeSelect ? typeSelect.value : 'application';
if (condType === 'always') {
conditions.push({ condition_type: 'always' });
} else if (condType === 'startup') {
conditions.push({ condition_type: 'startup' });
} else if (condType === 'time_of_day') {
conditions.push({
condition_type: 'time_of_day',
start_time: (row.querySelector('.condition-start-time') as HTMLInputElement).value || '00:00',
end_time: (row.querySelector('.condition-end-time') as HTMLInputElement).value || '23:59',
const typeSelect = row.querySelector('.rule-type-select') as HTMLSelectElement;
const ruleType = typeSelect ? typeSelect.value : 'application';
if (ruleType === 'startup') {
rules.push({ rule_type: 'startup' });
} else if (ruleType === 'time_of_day') {
rules.push({
rule_type: 'time_of_day',
start_time: (row.querySelector('.rule-start-time') as HTMLInputElement).value || '00:00',
end_time: (row.querySelector('.rule-end-time') as HTMLInputElement).value || '23:59',
});
} else if (condType === 'system_idle') {
conditions.push({
condition_type: 'system_idle',
idle_minutes: parseInt((row.querySelector('.condition-idle-minutes') as HTMLInputElement).value, 10) || 5,
when_idle: (row.querySelector('.condition-when-idle') as HTMLSelectElement).value === 'true',
} else if (ruleType === 'system_idle') {
rules.push({
rule_type: 'system_idle',
idle_minutes: parseInt((row.querySelector('.rule-idle-minutes') as HTMLInputElement).value, 10) || 5,
when_idle: (row.querySelector('.rule-when-idle') as HTMLSelectElement).value === 'true',
});
} else if (condType === 'display_state') {
conditions.push({
condition_type: 'display_state',
state: (row.querySelector('.condition-display-state') as HTMLSelectElement).value || 'on',
} else if (ruleType === 'display_state') {
rules.push({
rule_type: 'display_state',
state: (row.querySelector('.rule-display-state') as HTMLSelectElement).value || 'on',
});
} else if (condType === 'mqtt') {
conditions.push({
condition_type: 'mqtt',
topic: (row.querySelector('.condition-mqtt-topic') as HTMLInputElement).value.trim(),
payload: (row.querySelector('.condition-mqtt-payload') as HTMLInputElement).value,
match_mode: (row.querySelector('.condition-mqtt-match-mode') as HTMLSelectElement).value || 'exact',
} else if (ruleType === 'mqtt') {
rules.push({
rule_type: 'mqtt',
topic: (row.querySelector('.rule-mqtt-topic') as HTMLInputElement).value.trim(),
payload: (row.querySelector('.rule-mqtt-payload') as HTMLInputElement).value,
match_mode: (row.querySelector('.rule-mqtt-match-mode') as HTMLSelectElement).value || 'exact',
});
} else if (condType === 'webhook') {
const tokenInput = row.querySelector('.condition-webhook-token') as HTMLInputElement;
const cond: any = { condition_type: 'webhook' };
if (tokenInput && tokenInput.value) cond.token = tokenInput.value;
conditions.push(cond);
} else if (condType === 'home_assistant') {
conditions.push({
condition_type: 'home_assistant',
ha_source_id: (row.querySelector('.condition-ha-source-id') as HTMLSelectElement).value,
entity_id: (row.querySelector('.condition-ha-entity-id') as HTMLSelectElement).value.trim(),
state: (row.querySelector('.condition-ha-state') as HTMLInputElement).value,
match_mode: (row.querySelector('.condition-ha-match-mode') as HTMLSelectElement).value || 'exact',
} else if (ruleType === 'webhook') {
const tokenInput = row.querySelector('.rule-webhook-token') as HTMLInputElement;
const r: any = { rule_type: 'webhook' };
if (tokenInput && tokenInput.value) r.token = tokenInput.value;
rules.push(r);
} else if (ruleType === 'home_assistant') {
rules.push({
rule_type: 'home_assistant',
ha_source_id: (row.querySelector('.rule-ha-source-id') as HTMLSelectElement).value,
entity_id: (row.querySelector('.rule-ha-entity-id') as HTMLSelectElement).value.trim(),
state: (row.querySelector('.rule-ha-state') as HTMLInputElement).value,
match_mode: (row.querySelector('.rule-ha-match-mode') as HTMLSelectElement).value || 'exact',
});
} else {
const matchType = (row.querySelector('.condition-match-type') as HTMLSelectElement).value;
const appsText = (row.querySelector('.condition-apps') as HTMLTextAreaElement).value.trim();
const matchType = (row.querySelector('.rule-match-type') as HTMLSelectElement).value;
const appsText = (row.querySelector('.rule-apps') as HTMLTextAreaElement).value.trim();
const apps = appsText ? appsText.split('\n').map(a => a.trim()).filter(Boolean) : [];
conditions.push({ condition_type: 'application', apps, match_type: matchType });
rules.push({ rule_type: 'application', apps, match_type: matchType });
}
});
return conditions;
return rules;
}
export async function saveAutomationEditor() {
@@ -975,8 +973,8 @@ export async function saveAutomationEditor() {
const body = {
name,
enabled: enabledInput.checked,
condition_logic: logicSelect.value,
conditions: getAutomationEditorConditions(),
rule_logic: logicSelect.value,
rules: getAutomationEditorRules(),
scene_preset_id: (document.getElementById('automation-scene-id') as HTMLSelectElement).value || null,
deactivation_mode: (document.getElementById('automation-deactivation-mode') as HTMLSelectElement).value,
deactivation_scene_preset_id: (document.getElementById('automation-fallback-scene-id') as HTMLSelectElement).value || null,
@@ -1026,11 +1024,11 @@ export async function toggleAutomationEnabled(automationId: any, enable: any) {
}
export function copyWebhookUrl(btn: any) {
const input = btn.closest('.webhook-url-row').querySelector('.condition-webhook-url') as HTMLInputElement;
const input = btn.closest('.webhook-url-row').querySelector('.rule-webhook-url') as HTMLInputElement;
if (!input || !input.value) return;
const onCopied = () => {
const orig = btn.textContent;
btn.textContent = t('automations.condition.webhook.copied');
btn.textContent = t('automations.rule.webhook.copied');
setTimeout(() => { btn.textContent = orig; }, 1500);
};
if (navigator.clipboard && window.isSecureContext) {
@@ -3,7 +3,7 @@
*/
import { fetchWithAuth, escapeHtml } from '../core/api.ts';
import { _cachedSyncClocks, _cachedCSPTemplates, _cachedWeatherSources, _cachedValueSources, audioSourcesCache, streamsCache, colorStripSourcesCache, valueSourcesCache, csptCache, gradientsCache, weatherSourcesCache, GradientEntity } from '../core/state.ts';
import { _cachedSyncClocks, _cachedCSPTemplates, _cachedWeatherSources, _cachedValueSources, audioSourcesCache, streamsCache, colorStripSourcesCache, valueSourcesCache, csptCache, gradientsCache, weatherSourcesCache, GradientEntity, _cachedGameIntegrations, _cachedGameAdapters, gameIntegrationsCache } from '../core/state.ts';
import { t } from '../core/i18n.ts';
import { showToast, showConfirm, desktopFocus } from '../core/ui.ts';
import { Modal } from '../core/modal.ts';
@@ -14,13 +14,14 @@ import {
ICON_AUDIO_LOOPBACK, ICON_TIMER, ICON_LINK_SOURCE, ICON_FILM,
ICON_LINK, ICON_SPARKLES, ICON_ACTIVITY, ICON_CLOCK, ICON_BELL, ICON_TEST,
ICON_AUTOMATION, ICON_FAST_FORWARD, ICON_THERMOMETER, ICON_TRASH, ICON_PATTERN_TEMPLATE,
ICON_GAMEPAD,
} from '../core/icons.ts';
import * as P from '../core/icon-paths.ts';
import { wrapCard } from '../core/card-colors.ts';
import type { ColorStripSource } from '../types.ts';
import { bindableValue, bindableColor } from '../types.ts';
import { TagInput, renderTagChips } from '../core/tag-input.ts';
import { IconSelect, showTypePicker } from '../core/icon-select.ts';
import { IconSelect, showTypePicker, type IconSelectItem } from '../core/icon-select.ts';
import { EntitySelect } from '../core/entity-palette.ts';
import { BindableScalarWidget } from '../core/bindable-scalar.ts';
import { BindableColorWidget } from '../core/bindable-color.ts';
@@ -86,6 +87,10 @@ class CSSEditorModal extends Modal {
if (_kcInterpolationIconSelect) { _kcInterpolationIconSelect.destroy(); _kcInterpolationIconSelect = null; }
if (_kcSmoothingWidget) { _kcSmoothingWidget.destroy(); _kcSmoothingWidget = null; }
if (_kcBrightnessWidget) { _kcBrightnessWidget.destroy(); _kcBrightnessWidget = null; }
if (_gameEventIdleColorWidget) { _gameEventIdleColorWidget.destroy(); _gameEventIdleColorWidget = null; }
if (_cssGameIntegrationEntitySelect) { _cssGameIntegrationEntitySelect.destroy(); _cssGameIntegrationEntitySelect = null; }
_destroyCSSGameMappingIconSelects();
if (_cssGamePresetIconSelect) { _cssGamePresetIconSelect.destroy(); _cssGamePresetIconSelect = null; }
compositeDestroyEntitySelects();
}
@@ -141,6 +146,9 @@ class CSSEditorModal extends Modal {
kc_smoothing: _kcSmoothingWidget ? JSON.stringify(_kcSmoothingWidget.getValue()) : '0.3',
kc_brightness: _kcBrightnessWidget ? JSON.stringify(_kcBrightnessWidget.getValue()) : '1.0',
kc_rects: JSON.stringify(_kcEditorRects),
ge_integration: (document.getElementById('css-editor-game-integration') as HTMLInputElement)?.value || '',
ge_idle_color: _gameEventIdleColorWidget ? JSON.stringify(_gameEventIdleColorWidget.getValue()) : '[]',
ge_mappings: JSON.stringify(_cssGameMappings),
tags: JSON.stringify(_cssTagsInput ? _cssTagsInput.getValue() : []),
};
}
@@ -168,6 +176,7 @@ let _audioColorWidget: BindableColorWidget | null = null;
let _audioColorPeakWidget: BindableColorWidget | null = null;
let _apiInputFallbackColorWidget: BindableColorWidget | null = null;
let _candlelightColorWidget: BindableColorWidget | null = null;
let _gameEventIdleColorWidget: BindableColorWidget | null = null;
// ── EntitySelect instances for CSS editor ──
let _cssPictureSourceEntitySelect: any = null;
@@ -251,6 +260,7 @@ const CSS_TYPE_KEYS = [
'picture', 'picture_advanced', 'static', 'gradient', 'color_cycle',
'effect', 'composite', 'mapped', 'audio',
'api_input', 'notification', 'daylight', 'candlelight', 'weather', 'processed', 'key_colors',
'game_event',
];
function _buildCSSTypeItems() {
@@ -298,6 +308,7 @@ const CSS_SECTION_MAP: Record<string, string> = {
'weather': 'css-editor-weather-section',
'processed': 'css-editor-processed-section',
'key_colors': 'css-editor-key-colors-section',
'game_event': 'css-editor-game-event-section',
};
const CSS_ALL_SECTION_IDS = [...new Set(Object.values(CSS_SECTION_MAP))];
@@ -309,6 +320,7 @@ const CSS_TYPE_SETUP: Record<string, () => void> = {
gradient: () => { _ensureGradientPresetEntitySelect(); _ensureGradientEasingIconSelect(); requestAnimationFrame(() => gradientRenderAll()); },
notification: () => { ensureNotificationEffectIconSelect(); ensureNotificationFilterModeIconSelect(); },
candlelight: () => _ensureCandleTypeIconSelect(),
game_event: () => { _populateGameIntegrationDropdownCSS(); _initCSSGamePresetIconSelect(); },
weather: () => { weatherSourcesCache.fetch().then(() => _populateWeatherSourceDropdown()); },
composite: () => compositeRenderList(),
mapped: () => _mappedRenderList(),
@@ -569,6 +581,269 @@ function _ensureKcBrightnessWidget(): BindableScalarWidget {
return _kcBrightnessWidget;
}
// ── Game Event CSS helpers ──
function _ensureGameEventIdleColorWidget(): BindableColorWidget {
if (!_gameEventIdleColorWidget) {
_gameEventIdleColorWidget = new BindableColorWidget({
container: document.getElementById('css-editor-game-event-idle-color-container')!,
default: [0, 0, 0],
valueSources: () => _cachedValueSources,
idPrefix: 'css-editor-ge-idle-color',
});
}
return _gameEventIdleColorWidget;
}
let _cssGameIntegrationEntitySelect: EntitySelect | null = null;
function _populateGameIntegrationDropdownCSS(selectedId: string = '') {
const sel = document.getElementById('css-editor-game-integration') as HTMLSelectElement;
const integrations = _cachedGameIntegrations || [];
const prev = selectedId || sel.value;
sel.innerHTML = `<option value="">${t('common.none_no_input')}</option>` +
integrations.map(gi => `<option value="${gi.id}"${gi.id === prev ? ' selected' : ''}>${escapeHtml(gi.name)}</option>`).join('');
sel.value = prev || '';
if (_cssGameIntegrationEntitySelect) _cssGameIntegrationEntitySelect.destroy();
_cssGameIntegrationEntitySelect = new EntitySelect({
target: sel,
getItems: () => integrations.map(gi => ({
value: gi.id,
label: gi.name,
icon: ICON_GAMEPAD,
desc: gi.adapter_type,
})),
allowNone: true,
noneLabel: t('common.none_no_input'),
placeholder: t('palette.search'),
});
}
let _cssGameMappings: any[] = [];
let _cssGameMappingIconSelects: IconSelect[] = [];
let _cssGamePresetIconSelect: IconSelect | null = null;
function _destroyCSSGameMappingIconSelects() {
_cssGameMappingIconSelects.forEach(is => is.destroy());
_cssGameMappingIconSelects = [];
}
function _hexToRgbCSS(hex: string): number[] {
const m = hex.replace('#', '').match(/.{2}/g);
if (!m) return [255, 0, 0];
return m.map(c => parseInt(c, 16));
}
function _rgbToHexCSS(rgb: number[]): string {
return '#' + rgb.map(c => c.toString(16).padStart(2, '0')).join('');
}
function _getCSSGameAvailableEventTypes(): string[] {
const giId = (document.getElementById('css-editor-game-integration') as HTMLSelectElement)?.value;
if (giId) {
const gi = (_cachedGameIntegrations || []).find(g => g.id === giId);
if (gi) {
const adapter = (_cachedGameAdapters || []).find(a => a.adapter_type === gi.adapter_type);
if (adapter && adapter.supported_events.length > 0) return adapter.supported_events;
}
}
return ['kill', 'death', 'health', 'armor', 'round_start', 'round_end', 'bomb_planted', 'bomb_defused', 'assist', 'headshot'];
}
const _CSS_GE_EFFECT_TYPES: IconSelectItem[] = [
{ value: 'flash', label: 'Flash', icon: `<svg class="icon" viewBox="0 0 24 24">${P.zap}</svg>` },
{ value: 'pulse', label: 'Pulse', icon: `<svg class="icon" viewBox="0 0 24 24">${P.activity}</svg>` },
{ value: 'sweep', label: 'Sweep', icon: `<svg class="icon" viewBox="0 0 24 24">${P.fastForward}</svg>` },
{ value: 'color_shift', label: 'Color Shift', icon: `<svg class="icon" viewBox="0 0 24 24">${P.rainbow}</svg>` },
{ value: 'breathing', label: 'Breathing', icon: `<svg class="icon" viewBox="0 0 24 24">${P.heart}</svg>` },
];
const _CSS_GE_EVENT_ICONS: Record<string, string> = {
kill: P.crosshair, death: P.xIcon, health: P.heart, armor: P.shield,
round_start: P.play, round_end: P.square, bomb_planted: P.flame, bomb_defused: P.circleCheck,
assist: P.swords, headshot: P.target, damage: P.zap, gold: P.star,
level_up: P.trendingUp, respawn: P.refreshCw, item_pickup: P.packageIcon,
};
function _buildCSSGameEventTypeItems(): IconSelectItem[] {
return _getCSSGameAvailableEventTypes().map(et => ({
value: et,
label: et,
icon: `<svg class="icon" viewBox="0 0 24 24">${_CSS_GE_EVENT_ICONS[et] || P.circleDot}</svg>`,
}));
}
function _renderCSSGameMappingRow(mapping: any, index: number): string {
const eventTypes = _getCSSGameAvailableEventTypes();
const eventOptions = eventTypes.map(et =>
`<option value="${et}"${et === mapping.event_type ? ' selected' : ''}>${et}</option>`
).join('');
const effectOptions = _CSS_GE_EFFECT_TYPES.map(ef =>
`<option value="${ef.value}"${ef.value === mapping.effect_type ? ' selected' : ''}>${ef.label}</option>`
).join('');
const effectLabel = _CSS_GE_EFFECT_TYPES.find(ef => ef.value === mapping.effect_type)?.label || mapping.effect_type;
const hexColor = _rgbToHexCSS(mapping.color || [255, 0, 0]);
return `
<div class="gi-mapping-row" data-mapping-index="${index}">
<div class="gi-mapping-header">
<span class="gi-mapping-expand-btn">&#x25B6;</span>
<span class="gi-mapping-summary">
<span class="gi-mapping-summary-event">${escapeHtml(mapping.event_type)}</span>
<span class="gi-mapping-summary-effect">${escapeHtml(effectLabel)}</span>
<span class="gi-mapping-summary-color" style="background:${hexColor}"></span>
</span>
<button type="button" class="btn-remove-rule" onclick="event.stopPropagation(); removeCSSGameMapping(${index})" title="${t('common.delete')}">${ICON_TRASH}</button>
</div>
<div class="gi-mapping-body-wrapper">
<div class="gi-mapping-body">
<div class="gi-mapping-field-row">
<label>${t('game_integration.mapping.event_type')}</label>
<select data-field="event_type">${eventOptions}</select>
</div>
<div class="gi-mapping-field-row">
<label>${t('game_integration.mapping.effect_type')}</label>
<select data-field="effect_type">${effectOptions}</select>
</div>
<div class="gi-mapping-field-row">
<label>${t('game_integration.mapping.color')}</label>
<input type="color" data-field="color" value="${hexColor}">
</div>
<div class="gi-mapping-field-row">
<label>${t('game_integration.mapping.duration')}</label>
<input type="number" data-field="duration_ms" value="${mapping.duration_ms || 500}" min="50" max="10000" step="50">
</div>
<div class="gi-mapping-field-row">
<label>${t('game_integration.mapping.intensity')}</label>
<input type="range" data-field="intensity" value="${mapping.intensity ?? 1.0}" min="0" max="1" step="0.05"
oninput="this.title = this.value">
</div>
<div class="gi-mapping-field-row">
<label>${t('game_integration.mapping.priority')}</label>
<input type="number" data-field="priority" value="${mapping.priority || 5}" min="1" max="10">
</div>
</div>
</div>
</div>`;
}
function _wireCSSGameMappingRows(container: HTMLElement) {
container.querySelectorAll('.gi-mapping-header').forEach(header => {
const item = header.closest('.gi-mapping-row') as HTMLElement;
header.addEventListener('click', (e: Event) => {
if ((e.target as HTMLElement).closest('.btn-remove-rule')) return;
item.classList.toggle('gi-mapping-expanded');
});
});
container.querySelectorAll('.gi-mapping-row').forEach(row => {
const eventSel = row.querySelector('[data-field="event_type"]') as HTMLSelectElement | null;
const effectSel = row.querySelector('[data-field="effect_type"]') as HTMLSelectElement | null;
const colorInput = row.querySelector('input[data-field="color"]') as HTMLInputElement | null;
const summaryEvent = row.querySelector('.gi-mapping-summary-event') as HTMLElement | null;
const summaryEffect = row.querySelector('.gi-mapping-summary-effect') as HTMLElement | null;
const summaryColor = row.querySelector('.gi-mapping-summary-color') as HTMLElement | null;
if (eventSel) {
const is = new IconSelect({ target: eventSel, items: _buildCSSGameEventTypeItems(), columns: 4 });
_cssGameMappingIconSelects.push(is);
if (summaryEvent) {
eventSel.addEventListener('change', () => { summaryEvent.textContent = eventSel.value; });
}
}
if (effectSel) {
const is = new IconSelect({ target: effectSel, items: _CSS_GE_EFFECT_TYPES, columns: 3 });
_cssGameMappingIconSelects.push(is);
if (summaryEffect) {
effectSel.addEventListener('change', () => {
const label = _CSS_GE_EFFECT_TYPES.find(ef => ef.value === effectSel.value)?.label || effectSel.value;
summaryEffect.textContent = label;
});
}
}
if (colorInput && summaryColor) {
colorInput.addEventListener('input', () => { summaryColor.style.background = colorInput.value; });
}
});
}
function _renderCSSGameMappings(mappings: any[]) {
_cssGameMappings = [...mappings];
_destroyCSSGameMappingIconSelects();
const container = document.getElementById('css-editor-ge-mappings-list');
if (!container) return;
container.innerHTML = mappings.map((m, i) => _renderCSSGameMappingRow(m, i)).join('');
_wireCSSGameMappingRows(container);
}
function _collectCSSGameMappings(): any[] {
const rows = document.querySelectorAll('#css-editor-ge-mappings-list .gi-mapping-row');
return Array.from(rows).map(row => {
const eventType = (row.querySelector('[data-field="event_type"]') as HTMLSelectElement)?.value || 'kill';
const effectType = (row.querySelector('[data-field="effect_type"]') as HTMLSelectElement)?.value || 'flash';
const colorInput = (row.querySelector('input[data-field="color"]') as HTMLInputElement)?.value || '#ff0000';
const duration = parseFloat((row.querySelector('[data-field="duration_ms"]') as HTMLInputElement)?.value) || 500;
const intensity = parseFloat((row.querySelector('[data-field="intensity"]') as HTMLInputElement)?.value) || 1.0;
const priority = parseInt((row.querySelector('[data-field="priority"]') as HTMLInputElement)?.value) || 5;
return { event_type: eventType, effect_type: effectType, color: _hexToRgbCSS(colorInput), duration_ms: duration, intensity, priority };
});
}
export function addCSSGameMapping() {
const collected = _collectCSSGameMappings();
collected.push({
event_type: _getCSSGameAvailableEventTypes()[0] || 'kill',
effect_type: 'flash',
color: [255, 0, 0],
duration_ms: 500,
intensity: 1.0,
priority: 5,
});
_renderCSSGameMappings(collected);
}
export function removeCSSGameMapping(index: number) {
const collected = _collectCSSGameMappings();
collected.splice(index, 1);
_renderCSSGameMappings(collected);
}
export function onCSSGameMappingPresetChange() {
const sel = document.getElementById('css-editor-ge-mapping-preset') as HTMLSelectElement;
if (!sel.value) return;
const presets: Record<string, any[]> = {
fps_combat: [
{ event_type: 'kill', effect_type: 'flash', color: [0, 255, 0], duration_ms: 400, intensity: 1.0, priority: 8 },
{ event_type: 'death', effect_type: 'pulse', color: [255, 0, 0], duration_ms: 1500, intensity: 1.0, priority: 10 },
{ event_type: 'headshot', effect_type: 'flash', color: [255, 215, 0], duration_ms: 300, intensity: 1.0, priority: 9 },
{ event_type: 'health', effect_type: 'breathing', color: [255, 50, 50], duration_ms: 2000, intensity: 0.6, priority: 3 },
{ event_type: 'round_start', effect_type: 'sweep', color: [0, 100, 255], duration_ms: 800, intensity: 0.8, priority: 5 },
],
moba_health: [
{ event_type: 'health', effect_type: 'color_shift', color: [0, 255, 0], duration_ms: 1000, intensity: 0.7, priority: 4 },
{ event_type: 'kill', effect_type: 'flash', color: [255, 215, 0], duration_ms: 500, intensity: 1.0, priority: 8 },
{ event_type: 'death', effect_type: 'pulse', color: [255, 0, 0], duration_ms: 2000, intensity: 1.0, priority: 10 },
{ event_type: 'assist', effect_type: 'flash', color: [100, 200, 255], duration_ms: 300, intensity: 0.8, priority: 6 },
],
};
const preset = presets[sel.value];
if (preset) _renderCSSGameMappings(preset);
sel.value = '';
}
function _initCSSGamePresetIconSelect() {
const sel = document.getElementById('css-editor-ge-mapping-preset') as HTMLSelectElement | null;
if (!sel) return;
if (_cssGamePresetIconSelect) { _cssGamePresetIconSelect.destroy(); _cssGamePresetIconSelect = null; }
const items: IconSelectItem[] = [
{ value: '', label: t('game_integration.mapping.select_preset'), icon: '' },
{ value: 'fps_combat', label: t('game_integration.preset.fps_combat'), icon: `<svg class="icon" viewBox="0 0 24 24">${P.crosshair}</svg>` },
{ value: 'moba_health', label: t('game_integration.preset.moba_health'), icon: `<svg class="icon" viewBox="0 0 24 24">${P.heart}</svg>` },
];
_cssGamePresetIconSelect = new IconSelect({ target: sel, items, columns: 2 });
}
function _ensureAudioSensitivityWidget(): BindableScalarWidget {
if (!_audioSensitivityWidget) {
_audioSensitivityWidget = new BindableScalarWidget({
@@ -2107,6 +2382,31 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
};
},
},
game_event: {
load(css: any) {
_populateGameIntegrationDropdownCSS(css.game_integration_id || '');
_ensureGameEventIdleColorWidget().setValue(css.idle_color);
_renderCSSGameMappings(css.event_mappings || []);
},
reset() {
_populateGameIntegrationDropdownCSS('');
_ensureGameEventIdleColorWidget().setValue([0, 0, 0]);
_renderCSSGameMappings([]);
},
getPayload(name: any) {
const giId = (document.getElementById('css-editor-game-integration') as HTMLSelectElement).value;
if (!giId) {
cssEditorModal.showError(t('color_strip.game_event.error.no_integration'));
return null;
}
return {
name,
game_integration_id: giId,
idle_color: _ensureGameEventIdleColorWidget().getValue(),
event_mappings: _collectCSSGameMappings(),
};
},
},
};
/* ── Editor open/close ────────────────────────────────────────── */
@@ -649,18 +649,18 @@ function renderDashboardAutomation(automation: Automation, sceneMap: Map<string,
const isDisabled = !automation.enabled;
let condSummary = '';
if (automation.conditions.length > 0) {
const parts = automation.conditions.map(c => {
if (c.condition_type === 'application') {
const apps = (c.apps || []).join(', ');
const matchLabel = c.match_type === 'topmost' ? t('automations.condition.application.match_type.topmost') : t('automations.condition.application.match_type.running');
if (automation.rules.length > 0) {
const parts = automation.rules.map(r => {
if (r.rule_type === 'application') {
const apps = (r.apps || []).join(', ');
const matchLabel = r.match_type === 'topmost' ? t('automations.rule.application.match_type.topmost') : t('automations.rule.application.match_type.running');
return `${apps} (${matchLabel})`;
}
if (c.condition_type === 'startup') return t('automations.condition.startup');
if (c.condition_type === 'time_of_day') return t('automations.condition.time_of_day');
return t(`automations.condition.${c.condition_type}`) || c.condition_type;
if (r.rule_type === 'startup') return t('automations.rule.startup');
if (r.rule_type === 'time_of_day') return t('automations.rule.time_of_day');
return t(`automations.rule.${r.rule_type}`) || r.rule_type;
});
const logic = automation.condition_logic === 'and' ? ' & ' : ' | ';
const logic = automation.rule_logic === 'and' ? ' & ' : ' | ';
condSummary = parts.join(logic);
}
@@ -0,0 +1,761 @@
/**
* Game Integration — CRUD, cards, modal handlers, live event monitor.
*/
import {
gameIntegrationsCache, gameAdaptersCache,
_cachedGameIntegrations, _cachedGameAdapters,
} from '../core/state.ts';
import { fetchWithAuth, escapeHtml } from '../core/api.ts';
import { t } from '../core/i18n.ts';
import { Modal } from '../core/modal.ts';
import { showToast, showConfirm } from '../core/ui.ts';
import { CardSection } from '../core/card-sections.ts';
import { wrapCard } from '../core/card-colors.ts';
import { TagInput, renderTagChips } from '../core/tag-input.ts';
import { IconSelect, type IconSelectItem } from '../core/icon-select.ts';
import {
ICON_GAMEPAD, ICON_CLONE, ICON_EDIT, ICON_TEST, ICON_TRASH,
getGameAdapterIcon, ICON_CIRCLE_DOT,
} from '../core/icons.ts';
import * as P from '../core/icon-paths.ts';
import type {
GameIntegration, GameAdapterInfo, GameEventMapping, GameEventRecord, GameIntegrationStatus,
EffectPreset,
} from '../types.ts';
const _icon = (d: string) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
// ── Bulk actions ──
function _bulkDeleteGameIntegrations(ids: string[]) {
return Promise.allSettled(ids.map(id =>
fetchWithAuth(`/game-integrations/${id}`, { method: 'DELETE' })
)).then(results => {
const failed = results.filter(r => r.status === 'rejected' || ((r as any).value && !(r as any).value.ok)).length;
if (failed) showToast(`${ids.length - failed}/${ids.length} deleted`, 'warning');
else showToast(t('game_integration.deleted'), 'success');
gameIntegrationsCache.invalidate();
loadGameIntegrations();
});
}
const _gameIntegrationBulkActions = [{
key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger',
confirm: 'bulk.confirm_delete', handler: _bulkDeleteGameIntegrations,
}];
// ── CardSection ──
export const csGameIntegrations = new CardSection('game-integrations', {
titleKey: 'game_integration.section_title',
gridClass: 'templates-grid',
addCardOnclick: "showGameIntegrationEditor()",
keyAttr: 'data-gi-id',
emptyKey: 'section.empty.game_integrations',
bulkActions: _gameIntegrationBulkActions,
});
// ── Modal ──
let _giTagsInput: TagInput | null = null;
let _adapterTypeIconSelect: IconSelect | null = null;
let _mappingIconSelects: IconSelect[] = [];
let _presetIconSelect: IconSelect | null = null;
let _eventMonitorTimer: ReturnType<typeof setInterval> | null = null;
class GameIntegrationModal extends Modal {
constructor() { super('game-integration-modal'); }
snapshotValues() {
return {
name: (this.$('gi-name') as HTMLInputElement)?.value || '',
description: (this.$('gi-description') as HTMLInputElement)?.value || '',
adapterType: (this.$('gi-adapter-type') as HTMLSelectElement)?.value || '',
enabled: (this.$('gi-enabled') as HTMLInputElement)?.checked ? '1' : '0',
mappings: JSON.stringify(_collectMappings()),
tags: JSON.stringify(_giTagsInput ? _giTagsInput.getValue() : []),
config: JSON.stringify(_collectAdapterConfig()),
};
}
onForceClose() {
if (_giTagsInput) { _giTagsInput.destroy(); _giTagsInput = null; }
if (_adapterTypeIconSelect) { _adapterTypeIconSelect.destroy(); _adapterTypeIconSelect = null; }
if (_presetIconSelect) { _presetIconSelect.destroy(); _presetIconSelect = null; }
_destroyMappingIconSelects();
_stopEventMonitor();
}
}
const giModal = new GameIntegrationModal();
// ── Adapter config helpers ──
function _collectAdapterConfig(): Record<string, any> {
const container = document.getElementById('gi-adapter-config-fields');
if (!container) return {};
const config: Record<string, any> = {};
container.querySelectorAll('[data-config-key]').forEach(el => {
const key = (el as HTMLElement).dataset.configKey!;
if (el instanceof HTMLInputElement) {
if (el.type === 'number') config[key] = parseFloat(el.value) || 0;
else if (el.type === 'checkbox') config[key] = el.checked;
else config[key] = el.value;
}
});
return config;
}
function _renderAdapterConfigFields(adapter: GameAdapterInfo, existingConfig: Record<string, any> = {}) {
const container = document.getElementById('gi-adapter-config-fields')!;
if (!adapter.config_schema || adapter.config_schema.length === 0) {
container.innerHTML = `<p class="text-muted">${t('game_integration.no_config')}</p>`;
return;
}
container.innerHTML = adapter.config_schema.map(field => {
const val = existingConfig[field.name] ?? field.default ?? '';
const inputType = field.type === 'number' ? 'number' : field.type === 'boolean' ? 'checkbox' : 'text';
const checked = field.type === 'boolean' && val ? ' checked' : '';
const inputVal = field.type === 'boolean' ? '' : ` value="${escapeHtml(String(val))}"`;
return `
<div class="form-group">
<div class="label-row">
<label for="gi-config-${escapeHtml(field.name)}">${escapeHtml(field.label || field.name)}${field.required ? ' *' : ''}</label>
${field.hint ? `<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>` : ''}
</div>
${field.hint ? `<small class="input-hint" style="display:none">${escapeHtml(field.hint)}</small>` : ''}
<input type="${inputType}" id="gi-config-${escapeHtml(field.name)}"
data-config-key="${escapeHtml(field.name)}"${inputVal}${checked}>
</div>`;
}).join('');
}
let _currentSetupInstructions = '';
let _currentAdapterSupportsAutoSetup = false;
function _renderSetupInstructions(adapter: GameAdapterInfo) {
const btnWrapper = document.getElementById('gi-setup-instructions-btn-wrapper')!;
_currentSetupInstructions = adapter.setup_instructions || '';
_currentAdapterSupportsAutoSetup = adapter.supports_auto_setup || false;
const visible = _currentSetupInstructions || _currentAdapterSupportsAutoSetup;
btnWrapper.style.display = visible ? 'flex' : 'none';
btnWrapper.style.gap = '0.5rem';
const autoSetupBtn = document.getElementById('gi-auto-setup-btn');
if (autoSetupBtn) {
autoSetupBtn.style.display = _currentAdapterSupportsAutoSetup ? '' : 'none';
}
}
export function openSetupInstructions() {
if (!_currentSetupInstructions) return;
const overlay = document.getElementById('gi-setup-overlay');
const content = document.getElementById('gi-setup-overlay-content');
if (overlay && content) {
import('marked').then(({ marked }) => {
content.innerHTML = marked.parse(_currentSetupInstructions) as string;
overlay.style.display = 'flex';
});
}
}
export function closeSetupInstructions() {
const overlay = document.getElementById('gi-setup-overlay');
if (overlay) overlay.style.display = 'none';
}
export async function autoSetupGameIntegration() {
const id = (document.getElementById('gi-id') as HTMLInputElement)?.value;
if (!id) {
showToast(t('game_integration.auto_setup.save_first'), 'warning');
return;
}
try {
const res = await fetchWithAuth(`/game-integrations/${id}/auto-setup`, { method: 'POST' });
if (!res || !res.ok) {
const err = await res!.json();
showToast(err.detail || t('game_integration.auto_setup.failed'), 'error');
return;
}
const data = await res.json();
if (data.success) {
let msg = t('game_integration.auto_setup.success');
if (data.file_path) msg += `\n${data.file_path}`;
if (data.token_generated) msg += `\n${t('game_integration.auto_setup.token_generated')}`;
showToast(msg, 'success');
// Reload integration data in case auth token was generated
if (data.token_generated) {
gameIntegrationsCache.invalidate();
const integrations = await gameIntegrationsCache.fetch();
const gi = integrations.find(g => g.id === id);
if (gi) {
const adapters = await gameAdaptersCache.fetch();
const adapter = adapters.find(a => a.adapter_type === gi.adapter_type);
if (adapter) _renderAdapterConfigFields(adapter, gi.adapter_config || {});
}
}
} else {
showToast(data.message || t('game_integration.auto_setup.failed'), 'error');
}
} catch (e: any) {
if (e.isAuth) return;
showToast(e.message || t('game_integration.auto_setup.failed'), 'error');
}
}
// ── Event mapping helpers ──
let _currentMappings: GameEventMapping[] = [];
function _collectMappings(): GameEventMapping[] {
const rows = document.querySelectorAll('#gi-mappings-list .gi-mapping-row');
return Array.from(rows).map(row => {
const eventType = (row.querySelector('[data-field="event_type"]') as HTMLSelectElement)?.value || 'kill';
const effectType = (row.querySelector('[data-field="effect_type"]') as HTMLSelectElement)?.value || 'flash';
const colorInput = (row.querySelector('input[data-field="color"]') as HTMLInputElement)?.value || '#ff0000';
const duration = parseFloat((row.querySelector('[data-field="duration_ms"]') as HTMLInputElement)?.value) || 500;
const intensity = parseFloat((row.querySelector('[data-field="intensity"]') as HTMLInputElement)?.value) || 1.0;
const priority = parseInt((row.querySelector('[data-field="priority"]') as HTMLInputElement)?.value) || 5;
const rgb = _hexToRgb(colorInput);
return { event_type: eventType, effect_type: effectType, color: rgb, duration_ms: duration, intensity, priority };
});
}
function _hexToRgb(hex: string): number[] {
const m = hex.replace('#', '').match(/.{2}/g);
if (!m) return [255, 0, 0];
return m.map(c => parseInt(c, 16));
}
function _rgbToHex(rgb: number[]): string {
return '#' + rgb.map(c => c.toString(16).padStart(2, '0')).join('');
}
function _destroyMappingIconSelects() {
_mappingIconSelects.forEach(is => is.destroy());
_mappingIconSelects = [];
}
const EFFECT_TYPES: IconSelectItem[] = [
{ value: 'flash', label: 'Flash', icon: _icon(P.zap) },
{ value: 'pulse', label: 'Pulse', icon: _icon(P.activity) },
{ value: 'sweep', label: 'Sweep', icon: _icon(P.fastForward) },
{ value: 'color_shift', label: 'Color Shift', icon: _icon(P.rainbow) },
{ value: 'breathing', label: 'Breathing', icon: _icon(P.heart) },
];
/** Map well-known game event types to icons. Falls back to a generic icon. */
const _EVENT_TYPE_ICONS: Record<string, string> = {
kill: P.crosshair, death: P.xIcon, health: P.heart, armor: P.shield,
round_start: P.play, round_end: P.square, bomb_planted: P.flame, bomb_defused: P.circleCheck,
assist: P.swords, headshot: P.target, damage: P.zap, gold: P.star,
level_up: P.trendingUp, respawn: P.refreshCw, item_pickup: P.packageIcon,
};
function _buildEventTypeItems(): IconSelectItem[] {
return _getAvailableEventTypes().map(et => ({
value: et,
label: et,
icon: _icon(_EVENT_TYPE_ICONS[et] || P.circleDot),
}));
}
function _getAvailableEventTypes(): string[] {
const adapterType = (document.getElementById('gi-adapter-type') as HTMLSelectElement)?.value;
const adapter = _cachedGameAdapters.find(a => a.adapter_type === adapterType);
if (adapter && adapter.supported_events.length > 0) return adapter.supported_events;
return ['kill', 'death', 'health', 'armor', 'round_start', 'round_end', 'bomb_planted', 'bomb_defused', 'assist', 'headshot'];
}
function _renderMappingRow(mapping: GameEventMapping, index: number): string {
const eventTypes = _getAvailableEventTypes();
const eventOptions = eventTypes.map(et =>
`<option value="${et}"${et === mapping.event_type ? ' selected' : ''}>${et}</option>`
).join('');
const effectOptions = EFFECT_TYPES.map(ef =>
`<option value="${ef.value}"${ef.value === mapping.effect_type ? ' selected' : ''}>${ef.label}</option>`
).join('');
const effectLabel = EFFECT_TYPES.find(ef => ef.value === mapping.effect_type)?.label || mapping.effect_type;
const hexColor = _rgbToHex(mapping.color);
return `
<div class="gi-mapping-row" data-mapping-index="${index}">
<div class="gi-mapping-header">
<span class="gi-mapping-expand-btn">&#x25B6;</span>
<span class="gi-mapping-summary">
<span class="gi-mapping-summary-event">${escapeHtml(mapping.event_type)}</span>
<span class="gi-mapping-summary-effect">${escapeHtml(effectLabel)}</span>
<span class="gi-mapping-summary-color" style="background:${hexColor}"></span>
</span>
<button type="button" class="btn-remove-rule" onclick="event.stopPropagation(); removeGameMapping(${index})" title="${t('common.delete')}">${ICON_TRASH}</button>
</div>
<div class="gi-mapping-body-wrapper">
<div class="gi-mapping-body">
<div class="gi-mapping-field-row">
<label>${t('game_integration.mapping.event_type')}</label>
<select data-field="event_type">${eventOptions}</select>
</div>
<div class="gi-mapping-field-row">
<label>${t('game_integration.mapping.effect_type')}</label>
<select data-field="effect_type" id="gi-effect-type-${index}">${effectOptions}</select>
</div>
<div class="gi-mapping-field-row">
<label>${t('game_integration.mapping.color')}</label>
<input type="color" data-field="color" value="${hexColor}">
</div>
<div class="gi-mapping-field-row">
<label>${t('game_integration.mapping.duration')}</label>
<input type="number" data-field="duration_ms" value="${mapping.duration_ms}" min="50" max="10000" step="50">
</div>
<div class="gi-mapping-field-row">
<label>${t('game_integration.mapping.intensity')}</label>
<input type="range" data-field="intensity" value="${mapping.intensity}" min="0" max="1" step="0.05"
oninput="this.title = this.value">
</div>
<div class="gi-mapping-field-row">
<label>${t('game_integration.mapping.priority')}</label>
<input type="number" data-field="priority" value="${mapping.priority}" min="1" max="10">
</div>
</div>
</div>
</div>`;
}
function _renderMappings(mappings: GameEventMapping[]) {
_currentMappings = [...mappings];
const container = document.getElementById('gi-mappings-list')!;
_destroyMappingIconSelects();
container.innerHTML = mappings.map((m, i) => _renderMappingRow(m, i)).join('');
_wireMappingRows(container);
}
function _wireMappingRows(container: HTMLElement) {
// Expand/collapse on header click
container.querySelectorAll('.gi-mapping-header').forEach(header => {
const item = header.closest('.gi-mapping-row') as HTMLElement;
header.addEventListener('click', (e: Event) => {
const target = e.target as HTMLElement;
if (target.closest('.btn-remove-rule')) return;
item.classList.toggle('gi-mapping-expanded');
});
});
// Wire IconSelect + summary sync on each row
container.querySelectorAll('.gi-mapping-row').forEach(row => {
const eventSel = row.querySelector('[data-field="event_type"]') as HTMLSelectElement | null;
const effectSel = row.querySelector('[data-field="effect_type"]') as HTMLSelectElement | null;
const colorInput = row.querySelector('input[data-field="color"]') as HTMLInputElement | null;
const summaryEvent = row.querySelector('.gi-mapping-summary-event') as HTMLElement | null;
const summaryEffect = row.querySelector('.gi-mapping-summary-effect') as HTMLElement | null;
const summaryColor = row.querySelector('.gi-mapping-summary-color') as HTMLElement | null;
// Event type IconSelect
if (eventSel) {
const is = new IconSelect({ target: eventSel, items: _buildEventTypeItems(), columns: 4 });
_mappingIconSelects.push(is);
if (summaryEvent) {
eventSel.addEventListener('change', () => { summaryEvent.textContent = eventSel.value; });
}
}
// Effect type IconSelect
if (effectSel) {
const is = new IconSelect({ target: effectSel, items: EFFECT_TYPES, columns: 3 });
_mappingIconSelects.push(is);
if (summaryEffect) {
effectSel.addEventListener('change', () => {
const label = EFFECT_TYPES.find(ef => ef.value === effectSel.value)?.label || effectSel.value;
summaryEffect.textContent = label;
});
}
}
// Color swatch sync
if (colorInput && summaryColor) {
colorInput.addEventListener('input', () => { summaryColor.style.background = colorInput.value; });
}
});
}
export function addGameMapping() {
const newMapping: GameEventMapping = {
event_type: _getAvailableEventTypes()[0] || 'kill',
effect_type: 'flash',
color: [255, 0, 0],
duration_ms: 500,
intensity: 1.0,
priority: 5,
};
const collected = _collectMappings();
collected.push(newMapping);
_renderMappings(collected);
}
export function removeGameMapping(index: number) {
const collected = _collectMappings();
collected.splice(index, 1);
_renderMappings(collected);
}
let _cachedPresets: EffectPreset[] = [];
async function _loadPresets(): Promise<EffectPreset[]> {
if (_cachedPresets.length > 0) return _cachedPresets;
try {
const res = await fetchWithAuth('/game-integrations/presets');
if (res && res.ok) {
const data = await res.json();
_cachedPresets = data.presets || [];
}
} catch { /* ignore */ }
return _cachedPresets;
}
function _applyMappingPreset(presetKey: string) {
const preset = _cachedPresets.find(p => p.key === presetKey);
if (!preset) return;
// Map API effect field to frontend effect_type field
const mappings: GameEventMapping[] = preset.event_mappings.map(m => ({
event_type: m.event_type,
effect_type: (m as any).effect || (m as any).effect_type || 'flash',
color: m.color,
duration_ms: m.duration_ms,
intensity: m.intensity,
priority: m.priority,
}));
_renderMappings(mappings);
}
export function onMappingPresetChange() {
const sel = document.getElementById('gi-mapping-preset') as HTMLSelectElement;
if (sel.value) {
_applyMappingPreset(sel.value);
sel.value = '';
}
}
async function _populatePresetSelector() {
const sel = document.getElementById('gi-mapping-preset') as HTMLSelectElement;
if (!sel) return;
if (_presetIconSelect) { _presetIconSelect.destroy(); _presetIconSelect = null; }
const presets = await _loadPresets();
sel.innerHTML = `<option value="">${t('game_integration.mapping.select_preset')}</option>` +
presets.map(p => `<option value="${p.key}">${escapeHtml(p.name)}</option>`).join('');
if (presets.length > 0) {
const items: IconSelectItem[] = [
{ value: '', label: t('game_integration.mapping.select_preset'), icon: '' },
...presets.map(p => ({
value: p.key,
label: p.name,
icon: _icon(P.sparkles),
desc: p.description,
})),
];
_presetIconSelect = new IconSelect({ target: sel, items, columns: 2 });
}
}
// ── Live event monitor ──
function _stopEventMonitor() {
if (_eventMonitorTimer) {
clearInterval(_eventMonitorTimer);
_eventMonitorTimer = null;
}
}
function _startEventMonitor(integrationId: string) {
_stopEventMonitor();
const feed = document.getElementById('gi-event-feed');
if (!feed) return;
feed.innerHTML = `<div class="gi-event-waiting">${t('game_integration.events.waiting')}</div>`;
const poll = async () => {
try {
const res = await fetchWithAuth(`/game-integrations/${integrationId}/events`);
if (!res || !res.ok) return;
const data = await res.json();
const events: GameEventRecord[] = data.events || [];
if (events.length === 0) return;
feed.innerHTML = events.slice(0, 20).map(ev => {
const ts = new Date(ev.timestamp).toLocaleTimeString();
const valStr = ev.value !== undefined ? ` = ${ev.value}` : '';
return `<div class="gi-event-item">
<span class="gi-event-time">${ts}</span>
<span class="gi-event-type">${escapeHtml(ev.event_type)}</span>
<span class="gi-event-value">${valStr}</span>
</div>`;
}).join('');
} catch { /* ignore polling errors */ }
};
poll();
_eventMonitorTimer = setInterval(poll, 2000);
}
// ── Connection test ──
let _connectionTestTimer: ReturnType<typeof setInterval> | null = null;
export function testGameConnection() {
const id = (document.getElementById('gi-id') as HTMLInputElement)?.value;
if (!id) {
showToast(t('game_integration.error.save_first'), 'warning');
return;
}
const panel = document.getElementById('gi-test-panel')!;
panel.style.display = '';
panel.innerHTML = `<div class="gi-test-waiting">${ICON_CIRCLE_DOT} ${t('game_integration.test.waiting')}</div>`;
if (_connectionTestTimer) clearInterval(_connectionTestTimer);
let attempts = 0;
_connectionTestTimer = setInterval(async () => {
attempts++;
try {
const res = await fetchWithAuth(`/game-integrations/${id}/status`);
if (!res || !res.ok) return;
const status: GameIntegrationStatus = await res.json();
if (status.event_count > 0) {
clearInterval(_connectionTestTimer!);
_connectionTestTimer = null;
panel.innerHTML = `<div class="gi-test-success">${t('game_integration.test.success')} (${status.event_count})</div>`;
} else if (status.error) {
clearInterval(_connectionTestTimer!);
_connectionTestTimer = null;
panel.innerHTML = `<div class="gi-test-error">${t('game_integration.test.error')}: ${escapeHtml(status.error)}</div>`;
}
} catch { /* ignore */ }
if (attempts >= 30) {
clearInterval(_connectionTestTimer!);
_connectionTestTimer = null;
panel.innerHTML = `<div class="gi-test-timeout">${t('game_integration.test.timeout')}</div>`;
}
}, 2000);
}
// ── Card renderer ──
export function createGameIntegrationCard(gi: GameIntegration): string {
const adapterIcon = getGameAdapterIcon(gi.adapter_type);
const adapterName = _cachedGameAdapters.find(a => a.adapter_type === gi.adapter_type)?.display_name || gi.adapter_type;
const enabledClass = gi.enabled ? 'gi-status-active' : 'gi-status-inactive';
const enabledLabel = gi.enabled ? t('game_integration.status.active') : t('game_integration.status.inactive');
const mappingCount = gi.event_mappings?.length || 0;
return wrapCard({
type: 'template-card',
dataAttr: 'data-gi-id',
id: gi.id,
removeOnclick: `deleteGameIntegration('${gi.id}')`,
removeTitle: t('common.delete'),
content: `
<div class="template-card-header">
<div class="template-name" title="${escapeHtml(gi.name)}">${adapterIcon} ${escapeHtml(gi.name)}</div>
</div>
${gi.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(gi.description)}</div>` : ''}
<div class="stream-card-props">
<span class="stream-card-prop" title="${t('game_integration.adapter')}">${ICON_GAMEPAD} ${escapeHtml(adapterName)}</span>
<span class="stream-card-prop ${enabledClass}" title="${t('game_integration.status')}">${ICON_CIRCLE_DOT} ${enabledLabel}</span>
${mappingCount > 0 ? `<span class="stream-card-prop" title="${t('game_integration.mappings')}">${_icon(P.listChecks)} ${mappingCount}</span>` : ''}
</div>
${renderTagChips(gi.tags)}`,
actions: `
<button class="btn btn-icon btn-secondary" onclick="event.stopPropagation(); showGameEventMonitor('${gi.id}')" title="${t('game_integration.events.monitor')}">${ICON_TEST}</button>
<button class="btn btn-icon btn-secondary" onclick="cloneGameIntegration('${gi.id}')" title="${t('common.clone')}">${ICON_CLONE}</button>
<button class="btn btn-icon btn-secondary" onclick="showGameIntegrationEditor('${gi.id}')" title="${t('common.edit')}">${ICON_EDIT}</button>`,
});
}
// ── CRUD ──
export async function showGameIntegrationEditor(editId: string | null = null) {
const titleEl = document.getElementById('gi-title')!;
const idInput = document.getElementById('gi-id') as HTMLInputElement;
const nameInput = document.getElementById('gi-name') as HTMLInputElement;
const descInput = document.getElementById('gi-description') as HTMLInputElement;
const adapterSel = document.getElementById('gi-adapter-type') as HTMLSelectElement;
const enabledCheck = document.getElementById('gi-enabled') as HTMLInputElement;
const testPanel = document.getElementById('gi-test-panel')!;
// Reset form
idInput.value = '';
nameInput.value = '';
descInput.value = '';
enabledCheck.checked = true;
testPanel.style.display = 'none';
document.getElementById('gi-error')!.style.display = 'none';
// Ensure adapters are loaded
const adapters = await gameAdaptersCache.fetch();
adapterSel.innerHTML = adapters.map(a =>
`<option value="${a.adapter_type}">${escapeHtml(a.display_name)}</option>`
).join('');
// Setup adapter type IconSelect
if (_adapterTypeIconSelect) { _adapterTypeIconSelect.destroy(); _adapterTypeIconSelect = null; }
const adapterItems: IconSelectItem[] = adapters.map(a => ({
value: a.adapter_type,
label: a.display_name,
icon: getGameAdapterIcon(a.adapter_type),
desc: a.game_name,
}));
_adapterTypeIconSelect = new IconSelect({
target: adapterSel,
items: adapterItems,
columns: 3,
});
// Tags
if (_giTagsInput) { _giTagsInput.destroy(); _giTagsInput = null; }
_giTagsInput = new TagInput(document.getElementById('gi-tags-container')!);
if (editId) {
const integrations = await gameIntegrationsCache.fetch();
const gi = integrations.find(g => g.id === editId);
if (!gi) return;
idInput.value = gi.id;
nameInput.value = gi.name;
descInput.value = gi.description || '';
adapterSel.value = gi.adapter_type;
if (_adapterTypeIconSelect) _adapterTypeIconSelect.setValue(gi.adapter_type);
enabledCheck.checked = gi.enabled;
_giTagsInput.setValue(gi.tags || []);
const adapter = adapters.find(a => a.adapter_type === gi.adapter_type);
if (adapter) {
_renderAdapterConfigFields(adapter, gi.adapter_config || {});
_renderSetupInstructions(adapter);
}
_renderMappings(gi.event_mappings || []);
titleEl.innerHTML = `${ICON_GAMEPAD} ${t('game_integration.edit')}`;
// Start event monitor for existing integration
_startEventMonitor(gi.id);
} else {
titleEl.innerHTML = `${ICON_GAMEPAD} ${t('game_integration.add')}`;
_renderMappings([]);
// Show config for first adapter
if (adapters.length > 0) {
_renderAdapterConfigFields(adapters[0]);
_renderSetupInstructions(adapters[0]);
}
}
// Listen for adapter type changes
adapterSel.onchange = () => {
const adapter = adapters.find(a => a.adapter_type === adapterSel.value);
if (adapter) {
_renderAdapterConfigFields(adapter);
_renderSetupInstructions(adapter);
// Re-render mappings to update available event types
_renderMappings(_collectMappings());
}
};
// Populate preset selector from API
await _populatePresetSelector();
giModal.open();
giModal.snapshot();
}
export async function saveGameIntegration() {
const id = (document.getElementById('gi-id') as HTMLInputElement).value;
const name = (document.getElementById('gi-name') as HTMLInputElement).value.trim();
if (!name) { giModal.showError(t('game_integration.error.name_required')); return; }
const adapterType = (document.getElementById('gi-adapter-type') as HTMLSelectElement).value;
const description = (document.getElementById('gi-description') as HTMLInputElement).value.trim();
const enabled = (document.getElementById('gi-enabled') as HTMLInputElement).checked;
const adapterConfig = _collectAdapterConfig();
const eventMappings = _collectMappings();
const tags = _giTagsInput ? _giTagsInput.getValue() : [];
const payload = {
name, adapter_type: adapterType, adapter_config: adapterConfig,
event_mappings: eventMappings, enabled, description, tags,
};
try {
const url = id ? `/game-integrations/${id}` : '/game-integrations';
const method = id ? 'PUT' : 'POST';
const res = await fetchWithAuth(url, { method, body: JSON.stringify(payload) });
if (!res || !res.ok) {
const err = await res!.json();
throw new Error(err.detail || t('game_integration.error.save_failed'));
}
showToast(id ? t('game_integration.updated') : t('game_integration.created'), 'success');
gameIntegrationsCache.invalidate();
giModal.forceClose();
loadGameIntegrations();
} catch (e: any) {
if (e.isAuth) return;
giModal.showError(e.message);
}
}
export async function deleteGameIntegration(entityId: string) {
const ok = await showConfirm(t('game_integration.confirm_delete'));
if (!ok) return;
try {
await fetchWithAuth(`/game-integrations/${entityId}`, { method: 'DELETE' });
showToast(t('game_integration.deleted'), 'success');
gameIntegrationsCache.invalidate();
loadGameIntegrations();
} catch (e: any) {
if (e.isAuth) return;
showToast(e.message || t('game_integration.error.delete_failed'), 'error');
}
}
export async function cloneGameIntegration(entityId: string) {
const integrations = await gameIntegrationsCache.fetch();
const source = integrations.find(g => g.id === entityId);
if (!source) return;
await showGameIntegrationEditor(null);
(document.getElementById('gi-name') as HTMLInputElement).value = source.name + ' (Copy)';
(document.getElementById('gi-description') as HTMLInputElement).value = source.description || '';
const adapterSel = document.getElementById('gi-adapter-type') as HTMLSelectElement;
adapterSel.value = source.adapter_type;
if (_adapterTypeIconSelect) _adapterTypeIconSelect.setValue(source.adapter_type);
(document.getElementById('gi-enabled') as HTMLInputElement).checked = source.enabled;
if (_giTagsInput) _giTagsInput.setValue(source.tags || []);
const adapter = _cachedGameAdapters.find(a => a.adapter_type === source.adapter_type);
if (adapter) {
_renderAdapterConfigFields(adapter, source.adapter_config || {});
_renderSetupInstructions(adapter);
}
_renderMappings(source.event_mappings || []);
giModal.snapshot();
}
export function closeGameIntegrationModal() {
giModal.close();
}
// ── Event monitor (standalone, triggered from card) ──
export function showGameEventMonitor(integrationId: string) {
const gi = _cachedGameIntegrations.find(g => g.id === integrationId);
if (!gi) return;
// Open editor and start monitoring
showGameIntegrationEditor(integrationId);
}
// ── Load function (called from streams.ts) ──
export async function loadGameIntegrations() {
await Promise.all([
gameIntegrationsCache.fetch(),
gameAdaptersCache.fetch(),
]);
// Streams.ts handles rendering via its own renderPictureSourcesList
if (window.loadPictureSources) window.loadPictureSources();
}
@@ -39,6 +39,8 @@ import {
colorStripSourcesCache,
csptCache, stripFiltersCache,
gradientsCache, GradientEntity,
gameIntegrationsCache, gameAdaptersCache,
_cachedGameIntegrations, _cachedGameAdapters,
} from '../core/state.ts';
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.ts';
import { t } from '../core/i18n.ts';
@@ -55,12 +57,14 @@ import { createHASourceCard, initHASourceDelegation } from './home-assistant-sou
import { createAssetCard, initAssetDelegation } from './assets.ts';
import { createColorStripCard } from './color-strips.ts';
import { initAudioSourceDelegation } from './audio-sources.ts';
import { createGameIntegrationCard, csGameIntegrations } from './game-integration.ts';
import {
getEngineIcon, getAudioEngineIcon, getPictureSourceIcon, getAudioSourceIcon, getColorStripIcon,
ICON_TEMPLATE, ICON_CLONE, ICON_EDIT, ICON_TEST, ICON_LINK_SOURCE,
ICON_FPS, ICON_WEB, ICON_VALUE_SOURCE, ICON_CLOCK, ICON_AUDIO_LOOPBACK, ICON_AUDIO_INPUT,
ICON_AUDIO_TEMPLATE, ICON_MONITOR, ICON_WRENCH, ICON_RADIO, ICON_ACTIVITY,
ICON_CAPTURE_TEMPLATE, ICON_PP_TEMPLATE, ICON_CSPT, ICON_HELP, ICON_TRASH, ICON_PALETTE, ICON_ASSET,
ICON_GAMEPAD,
getAssetTypeIcon,
} from '../core/icons.ts';
import * as P from '../core/icon-paths.ts';
@@ -292,6 +296,8 @@ export async function loadPictureSources() {
colorStripSourcesCache.fetch(),
csptCache.fetch(),
gradientsCache.fetch(),
gameIntegrationsCache.fetch(),
gameAdaptersCache.fetch(),
filtersCache.data.length === 0 ? filtersCache.fetch() : Promise.resolve(filtersCache.data),
]);
renderPictureSourcesList(streams);
@@ -346,6 +352,7 @@ const _streamSectionMap = {
sync: [csSyncClocks],
weather: [csWeatherSources],
home_assistant: [csHASources],
game: [csGameIntegrations],
};
type StreamCardRenderer = (stream: any) => string;
@@ -574,6 +581,7 @@ function renderPictureSourcesList(streams: any) {
{ key: 'weather', icon: `<svg class="icon" viewBox="0 0 24 24">${P.cloudSun}</svg>`, titleKey: 'streams.group.weather', count: _cachedWeatherSources.length },
{ key: 'home_assistant', icon: `<svg class="icon" viewBox="0 0 24 24">${P.home}</svg>`, titleKey: 'streams.group.home_assistant', count: _cachedHASources.length },
{ key: 'assets', icon: ICON_ASSET, titleKey: 'streams.group.assets', count: _cachedAssets.length },
{ key: 'game', icon: ICON_GAMEPAD, titleKey: 'streams.group.game', count: _cachedGameIntegrations.length },
];
// Build tree navigation structure
@@ -626,6 +634,7 @@ function renderPictureSourcesList(streams: any) {
children: [
{ key: 'weather', titleKey: 'streams.group.weather', icon: `<svg class="icon" viewBox="0 0 24 24">${P.cloudSun}</svg>`, count: _cachedWeatherSources.length },
{ key: 'home_assistant', titleKey: 'streams.group.home_assistant', icon: `<svg class="icon" viewBox="0 0 24 24">${P.home}</svg>`, count: _cachedHASources.length },
{ key: 'game', titleKey: 'streams.group.game', icon: ICON_GAMEPAD, count: _cachedGameIntegrations.length },
]
},
{
@@ -797,6 +806,7 @@ function renderPictureSourcesList(streams: any) {
const haSourceItems = csHASources.applySortOrder(_cachedHASources.map(s => ({ key: s.id, html: createHASourceCard(s) })));
const assetItems = csAssets.applySortOrder(_cachedAssets.map(a => ({ key: a.id, html: createAssetCard(a) })));
const csptItems = csCSPTemplates.applySortOrder(csptTemplates.map(t => ({ key: t.id, html: renderCSPTCard(t) })));
const gameIntegrationItems = csGameIntegrations.applySortOrder(_cachedGameIntegrations.map(g => ({ key: g.id, html: createGameIntegrationCard(g) })));
if (csRawStreams.isMounted()) {
// Incremental update: reconcile cards in-place
@@ -817,6 +827,7 @@ function renderPictureSourcesList(streams: any) {
weather: _cachedWeatherSources.length,
home_assistant: _cachedHASources.length,
assets: _cachedAssets.length,
game: _cachedGameIntegrations.length,
});
csRawStreams.reconcile(rawStreamItems);
csRawTemplates.reconcile(rawTemplateItems);
@@ -836,6 +847,7 @@ function renderPictureSourcesList(streams: any) {
csWeatherSources.reconcile(weatherSourceItems);
csHASources.reconcile(haSourceItems);
csAssets.reconcile(assetItems);
csGameIntegrations.reconcile(gameIntegrationItems);
} else {
// First render: build full HTML
const panels = tabs.map(tab => {
@@ -856,13 +868,14 @@ function renderPictureSourcesList(streams: any) {
else if (tab.key === 'weather') panelContent = csWeatherSources.render(weatherSourceItems);
else if (tab.key === 'home_assistant') panelContent = csHASources.render(haSourceItems);
else if (tab.key === 'assets') panelContent = csAssets.render(assetItems);
else if (tab.key === 'game') panelContent = csGameIntegrations.render(gameIntegrationItems);
else if (tab.key === 'video') panelContent = csVideoStreams.render(videoItems);
else panelContent = csStaticStreams.render(staticItems);
return `<div class="stream-tab-panel${tab.key === activeTab ? ' active' : ''}" id="stream-tab-${tab.key}">${panelContent}</div>`;
}).join('');
container.innerHTML = panels;
CardSection.bindAll([csRawStreams, csRawTemplates, csProcStreams, csProcTemplates, csCSPTemplates, csColorStrips, csGradients, csAudioMulti, csAudioMono, csAudioBandExtract, csAudioTemplates, csStaticStreams, csVideoStreams, csValueSources, csSyncClocks, csWeatherSources, csHASources, csAssets]);
CardSection.bindAll([csRawStreams, csRawTemplates, csProcStreams, csProcTemplates, csCSPTemplates, csColorStrips, csGradients, csAudioMulti, csAudioMono, csAudioBandExtract, csAudioTemplates, csStaticStreams, csVideoStreams, csValueSources, csSyncClocks, csWeatherSources, csHASources, csAssets, csGameIntegrations]);
// Event delegation for card actions (replaces inline onclick handlers)
initSyncClockDelegation(container);
@@ -889,6 +902,7 @@ function renderPictureSourcesList(streams: any) {
'weather-sources': 'weather',
'ha-sources': 'home_assistant',
'assets': 'assets',
'game-integrations': 'game',
});
}
}
@@ -13,6 +13,7 @@
import {
_cachedValueSources, _cachedAudioSources, _cachedStreams, apiKey, valueSourcesCache,
_cachedHASources, _cachedColorStripSources, gradientsCache, GradientEntity,
_cachedGameIntegrations, _cachedGameAdapters, gameIntegrationsCache, gameAdaptersCache,
} from '../core/state.ts';
import { API_BASE, fetchWithAuth, escapeHtml } from '../core/api.ts';
import { t } from '../core/i18n.ts';
@@ -23,7 +24,7 @@ import {
ICON_CLONE, ICON_EDIT, ICON_TEST,
ICON_LED_PREVIEW, ICON_ACTIVITY, ICON_TIMER, ICON_MOVE_VERTICAL, ICON_CLOCK,
ICON_MUSIC, ICON_TRENDING_UP, ICON_MAP_PIN, ICON_MONITOR, ICON_REFRESH, ICON_TRASH,
ICON_HOME, ICON_RAINBOW, ICON_LINK, ICON_DROPLETS,
ICON_HOME, ICON_RAINBOW, ICON_LINK, ICON_DROPLETS, ICON_GAMEPAD,
} from '../core/icons.ts';
import { wrapCard } from '../core/card-colors.ts';
import { TagInput, renderTagChips } from '../core/tag-input.ts';
@@ -65,6 +66,7 @@ class ValueSourceModal extends Modal {
if (_vsGradientEasingIconSelect) { _vsGradientEasingIconSelect.destroy(); _vsGradientEasingIconSelect = null; }
if (_vsBehaviorIconSelect) { _vsBehaviorIconSelect.destroy(); _vsBehaviorIconSelect = null; }
if (_vsMetricIconSelect) { _vsMetricIconSelect.destroy(); _vsMetricIconSelect = null; }
if (_vsGameIntegrationEntitySelect) { _vsGameIntegrationEntitySelect.destroy(); _vsGameIntegrationEntitySelect = null; }
}
snapshotValues() {
@@ -136,13 +138,16 @@ function _autoGenerateVSName() {
} else if (type === 'system_metrics') {
const metric = (document.getElementById('value-source-metric') as HTMLSelectElement).value;
detail = t(`value_source.metric.${metric}`);
} else if (type === 'game_event') {
const eventType = (document.getElementById('value-source-game-event-type') as HTMLSelectElement)?.value;
if (eventType) detail = eventType;
}
(document.getElementById('value-source-name') as HTMLInputElement).value = detail ? `${typeLabel} · ${detail}` : typeLabel;
}
/* ── Icon-grid type selector ──────────────────────────────────── */
const VS_FLOAT_TYPE_KEYS = ['static', 'animated', 'audio', 'adaptive_time', 'adaptive_scene', 'daylight', 'ha_entity', 'system_metrics'];
const VS_FLOAT_TYPE_KEYS = ['static', 'animated', 'audio', 'adaptive_time', 'adaptive_scene', 'daylight', 'ha_entity', 'system_metrics', 'game_event'];
const VS_COLOR_TYPE_KEYS = ['static_color', 'animated_color', 'adaptive_time_color', 'gradient_map', 'css_extract'];
const VS_TYPE_KEYS = [...VS_FLOAT_TYPE_KEYS, ...VS_COLOR_TYPE_KEYS];
@@ -348,6 +353,61 @@ function _onMetricChange(metric: string) {
if (sensorFields) sensorFields.style.display = sensorMetrics.includes(metric) ? '' : 'none';
}
// ── Game Event Value Source helpers ──
let _vsGameIntegrationEntitySelect: EntitySelect | null = null;
function _populateVSGameIntegrationDropdown(selectedId: string = '') {
const sel = document.getElementById('value-source-game-integration') as HTMLSelectElement;
const integrations = _cachedGameIntegrations || [];
const prev = selectedId || sel.value;
sel.innerHTML = `<option value="">\u2014</option>` +
integrations.map(gi => `<option value="${gi.id}"${gi.id === prev ? ' selected' : ''}>${escapeHtml(gi.name)}</option>`).join('');
sel.value = prev || '';
if (_vsGameIntegrationEntitySelect) _vsGameIntegrationEntitySelect.destroy();
_vsGameIntegrationEntitySelect = new EntitySelect({
target: sel,
getItems: () => integrations.map(gi => ({
value: gi.id,
label: gi.name,
icon: ICON_GAMEPAD,
desc: gi.adapter_type,
})),
placeholder: t('palette.search'),
});
// Update event type dropdown when integration changes
sel.onchange = () => _populateVSGameEventTypeDropdown('');
}
function _populateVSGameEventTypeDropdown(selectedType: string = '') {
const eventTypeSel = document.getElementById('value-source-game-event-type') as HTMLSelectElement;
const giId = (document.getElementById('value-source-game-integration') as HTMLSelectElement)?.value;
// Get continuous events from the selected integration's adapter
const CONTINUOUS_EVENTS = ['health', 'armor', 'mana', 'ammo', 'stamina', 'shield', 'score', 'gold', 'xp', 'level'];
let eventTypes = CONTINUOUS_EVENTS;
if (giId) {
const gi = (_cachedGameIntegrations || []).find(g => g.id === giId);
if (gi) {
const adapter = (_cachedGameAdapters || []).find(a => a.adapter_type === gi.adapter_type);
if (adapter && adapter.supported_events.length > 0) {
// Filter to continuous events only
eventTypes = adapter.supported_events.filter(e => CONTINUOUS_EVENTS.includes(e));
if (eventTypes.length === 0) eventTypes = adapter.supported_events;
}
}
}
const prev = selectedType || eventTypeSel.value;
eventTypeSel.innerHTML = eventTypes.map(et =>
`<option value="${et}"${et === prev ? ' selected' : ''}>${et}</option>`
).join('');
if (prev && eventTypes.includes(prev)) eventTypeSel.value = prev;
}
function _ensureVSTypeIconSelect() {
const sel = document.getElementById('value-source-type');
if (!sel) return;
@@ -484,6 +544,14 @@ export async function showValueSourceModal(editData: any, presetType: any = null
_setSlider('value-source-poll-interval', editData.poll_interval ?? 1.0);
_setSlider('value-source-sysmetric-smoothing', editData.smoothing ?? 0);
_onMetricChange(editData.metric || 'cpu_load');
} else if (editData.source_type === 'game_event') {
_populateVSGameIntegrationDropdown(editData.game_integration_id || '');
_populateVSGameEventTypeDropdown(editData.event_type || 'health');
(document.getElementById('value-source-ge-min') as HTMLInputElement).value = String(editData.min_game_value ?? 0);
(document.getElementById('value-source-ge-max') as HTMLInputElement).value = String(editData.max_game_value ?? 100);
_setSlider('value-source-ge-smoothing', editData.smoothing ?? 0);
_setSlider('value-source-ge-default', editData.default_value ?? 0.5);
_setSlider('value-source-ge-timeout', editData.timeout ?? 5.0);
}
} else {
(document.getElementById('value-source-name') as HTMLInputElement).value = '';
@@ -590,6 +658,10 @@ export function onValueSourceTypeChange() {
_ensureMetricIconSelect();
_onMetricChange((document.getElementById('value-source-metric') as HTMLSelectElement).value);
}
(document.getElementById('value-source-game-event-section') as HTMLElement).style.display = type === 'game_event' ? '' : 'none';
if (type === 'game_event') {
_populateVSGameIntegrationDropdown('');
}
(document.getElementById('value-source-adaptive-range-section') as HTMLElement).style.display =
(type === 'adaptive_time' || type === 'adaptive_scene' || type === 'daylight') ? '' : 'none';
@@ -754,6 +826,19 @@ export async function saveValueSource() {
payload.sensor_label = (document.getElementById('value-source-sensor-label') as HTMLInputElement).value;
payload.poll_interval = parseFloat((document.getElementById('value-source-poll-interval') as HTMLInputElement).value) || 1.0;
payload.smoothing = parseFloat((document.getElementById('value-source-sysmetric-smoothing') as HTMLInputElement).value) || 0;
} else if (sourceType === 'game_event') {
payload.game_integration_id = (document.getElementById('value-source-game-integration') as HTMLSelectElement).value;
payload.event_type = (document.getElementById('value-source-game-event-type') as HTMLSelectElement).value;
payload.min_game_value = parseFloat((document.getElementById('value-source-ge-min') as HTMLInputElement).value) || 0;
payload.max_game_value = parseFloat((document.getElementById('value-source-ge-max') as HTMLInputElement).value) || 100;
payload.smoothing = parseFloat((document.getElementById('value-source-ge-smoothing') as HTMLInputElement).value) || 0;
payload.default_value = parseFloat((document.getElementById('value-source-ge-default') as HTMLInputElement).value) || 0.5;
payload.timeout = parseFloat((document.getElementById('value-source-ge-timeout') as HTMLInputElement).value) || 5.0;
if (!payload.game_integration_id) {
errorEl.textContent = t('value_source.game_event.integration') + ' required';
errorEl.style.display = '';
return;
}
}
try {
+19 -1
View File
@@ -178,7 +178,7 @@ interface Window {
openAutomationEditor: (...args: any[]) => any;
closeAutomationEditorModal: (...args: any[]) => any;
saveAutomationEditor: (...args: any[]) => any;
addAutomationCondition: (...args: any[]) => any;
addAutomationRule: (...args: any[]) => any;
toggleAutomationEnabled: (...args: any[]) => any;
cloneAutomation: (...args: any[]) => any;
deleteAutomation: (...args: any[]) => any;
@@ -194,6 +194,21 @@ interface Window {
deleteScenePreset: (...args: any[]) => any;
addSceneTarget: (...args: any[]) => any;
// ─── Game Integration ───
showGameIntegrationEditor: (...args: any[]) => any;
saveGameIntegration: (...args: any[]) => any;
closeGameIntegrationModal: (...args: any[]) => any;
cloneGameIntegration: (...args: any[]) => any;
deleteGameIntegration: (...args: any[]) => any;
addGameMapping: (...args: any[]) => any;
removeGameMapping: (...args: any[]) => any;
onMappingPresetChange: (...args: any[]) => any;
testGameConnection: (...args: any[]) => any;
showGameEventMonitor: (...args: any[]) => any;
openSetupInstructions: (...args: any[]) => any;
closeSetupInstructions: (...args: any[]) => any;
autoSetupGameIntegration: (...args: any[]) => any;
// ─── Device Discovery ───
onDeviceTypeChanged: (...args: any[]) => any;
updateBaudFpsHint: (...args: any[]) => any;
@@ -264,6 +279,9 @@ startTargetOverlay: (...args: any[]) => any;
applyCssTestSettings: (...args: any[]) => any;
fireCssTestNotification: (...args: any[]) => any;
fireCssTestNotificationLayer: (...args: any[]) => any;
addCSSGameMapping: () => void;
removeCSSGameMapping: (index: number) => void;
onCSSGameMappingPresetChange: () => void;
// ─── Audio Sources ───
showAudioSourceModal: (...args: any[]) => any;
+98 -3
View File
@@ -135,7 +135,8 @@ export type CSSSourceType =
| 'picture' | 'picture_advanced' | 'static' | 'gradient'
| 'color_cycle' | 'effect' | 'composite' | 'mapped'
| 'audio' | 'api_input' | 'notification' | 'daylight'
| 'candlelight' | 'processed' | 'weather' | 'key_colors';
| 'candlelight' | 'processed' | 'weather' | 'key_colors'
| 'game_event';
export interface ColorStop {
position: number;
@@ -282,6 +283,11 @@ export interface ColorStripSource {
// Key Colors
rectangles?: KeyColorRectangle[];
brightness?: BindableFloat;
// Game Event
game_integration_id?: string;
idle_color?: BindableColor;
event_mappings?: GameEventMapping[];
}
// ── Pattern Template ──────────────────────────────────────────
@@ -310,7 +316,8 @@ export type ValueSourceType =
| 'static' | 'animated' | 'audio'
| 'adaptive_time' | 'adaptive_scene' | 'daylight'
| 'static_color' | 'animated_color' | 'adaptive_time_color'
| 'ha_entity' | 'gradient_map' | 'css_extract';
| 'ha_entity' | 'gradient_map' | 'css_extract'
| 'system_metrics' | 'game_event';
export interface SchedulePoint {
time: string;
@@ -449,6 +456,18 @@ export interface SystemMetricsValueSource extends ValueSourceBase {
smoothing: number;
}
export interface GameEventValueSource extends ValueSourceBase {
source_type: 'game_event';
return_type: 'float';
game_integration_id: string;
event_type: string;
min_game_value: number;
max_game_value: number;
smoothing: number;
default_value: number;
timeout: number;
}
export type ValueSource =
| StaticValueSource
| AnimatedValueSource
@@ -462,7 +481,8 @@ export type ValueSource =
| HAEntityValueSource
| GradientMapValueSource
| CSSExtractValueSource
| SystemMetricsValueSource;
| SystemMetricsValueSource
| GameEventValueSource;
// ── Audio Source ───────────────────────────────────────────────
@@ -834,6 +854,81 @@ export interface AutomationListResponse {
count: number;
}
// ── Game Integration ─────────────────────────────────────────
export interface GameEventMapping {
event_type: string;
effect_type: string;
color: number[];
duration_ms: number;
intensity: number;
priority: number;
}
export interface GameIntegration {
id: string;
name: string;
adapter_type: string;
adapter_config: Record<string, any>;
event_mappings: GameEventMapping[];
enabled: boolean;
description?: string;
tags: string[];
created_at: string;
updated_at: string;
}
export interface GameIntegrationListResponse {
integrations: GameIntegration[];
count: number;
}
export interface GameAdapterConfigField {
name: string;
type: string;
label?: string;
default?: any;
required?: boolean;
hint?: string;
}
export interface GameAdapterInfo {
adapter_type: string;
display_name: string;
game_name: string;
supported_events: string[];
config_schema: GameAdapterConfigField[];
setup_instructions?: string;
supports_auto_setup?: boolean;
}
export interface GameAdapterListResponse {
adapters: GameAdapterInfo[];
}
export interface GameEventRecord {
timestamp: string;
event_type: string;
value?: number;
data?: Record<string, any>;
}
export interface GameIntegrationStatus {
integration_id: string;
connected: boolean;
last_event_at?: string;
event_count: number;
error?: string;
}
export interface EffectPreset {
key: string;
name: string;
description: string;
target_game_types: string[];
event_mappings: GameEventMapping[];
}
// ── Component Option Types (re-exported from authoritative sources) ───
export type { IconSelectItem, IconSelectOpts } from './core/icon-select.ts';
@@ -2150,5 +2150,91 @@
"donation.about_title": "About LedGrab",
"donation.about_opensource": "LedGrab is open-source software, free to use and modify.",
"donation.about_donate": "Support development",
"donation.about_license": "MIT License"
"donation.about_license": "MIT License",
"streams.group.game": "Game Integration",
"tree.group.game": "Game",
"game_integration.section_title": "Game Integrations",
"section.empty.game_integrations": "No game integrations yet. Click + to create one.",
"game_integration.add": "Add Game Integration",
"game_integration.edit": "Edit Game Integration",
"game_integration.created": "Game integration created",
"game_integration.updated": "Game integration updated",
"game_integration.deleted": "Game integration deleted",
"game_integration.confirm_delete": "Delete this game integration?",
"game_integration.error.name_required": "Name is required",
"game_integration.error.save_failed": "Failed to save game integration",
"game_integration.error.delete_failed": "Failed to delete game integration",
"game_integration.error.save_first": "Save the integration first to test the connection",
"game_integration.name": "Name:",
"game_integration.name.hint": "A descriptive name for this game integration",
"game_integration.description": "Description:",
"game_integration.description.hint": "Optional description of what this integration does",
"game_integration.enabled": "Enabled",
"game_integration.adapter_type": "Game / Adapter:",
"game_integration.adapter_type.hint": "Select the game or adapter type for this integration",
"game_integration.adapter_config": "Adapter Configuration",
"game_integration.no_config": "No configuration required for this adapter.",
"game_integration.setup_instructions": "Setup Instructions",
"game_integration.setup_instructions.hint": "Follow these steps to configure your game to send data to this integration",
"game_integration.event_mappings": "Event Mappings",
"game_integration.event_mappings.hint": "Map game events to LED effects. Each event type can trigger a different visual effect.",
"game_integration.mapping.add": "+ Add Mapping",
"game_integration.mapping.event_type": "Event",
"game_integration.mapping.effect_type": "Effect",
"game_integration.mapping.color": "Color",
"game_integration.mapping.duration": "Duration (ms)",
"game_integration.mapping.intensity": "Intensity",
"game_integration.mapping.priority": "Priority",
"game_integration.mapping.select_preset": "Load preset...",
"game_integration.preset.select": "Load preset...",
"game_integration.preset.fps_combat": "FPS Combat",
"game_integration.preset.moba_health": "MOBA Health",
"game_integration.adapter": "Adapter",
"game_integration.status": "Status",
"game_integration.status.active": "Active",
"game_integration.status.inactive": "Inactive",
"game_integration.mappings": "Mappings",
"game_integration.events.title": "Live Events",
"game_integration.events.waiting": "Waiting for events...",
"game_integration.events.monitor": "Event Monitor",
"game_integration.test.button": "Test Connection",
"game_integration.test.waiting": "Waiting for events from game...",
"game_integration.test.success": "Connection successful! Received events.",
"game_integration.test.error": "Connection error",
"game_integration.test.timeout": "No events received within timeout period.",
"game_integration.auto_setup": "Auto Setup",
"game_integration.auto_setup.success": "Configuration file written successfully",
"game_integration.auto_setup.failed": "Auto setup failed",
"game_integration.auto_setup.not_supported": "This adapter does not support auto setup",
"game_integration.auto_setup.game_not_found": "Game installation not found",
"game_integration.auto_setup.token_generated": "Auth token was automatically generated",
"game_integration.auto_setup.save_first": "Save the integration first before running auto setup",
"color_strip.type.game_event": "Game Event",
"color_strip.type.game_event.desc": "LED effects triggered by game events",
"color_strip.game_event.integration": "Game Integration:",
"color_strip.game_event.integration.hint": "Select the game integration that provides events for this source.",
"color_strip.game_event.idle_color": "Idle Color:",
"color_strip.game_event.idle_color.hint": "LED color when no game events are active.",
"color_strip.game_event.event_mappings": "Event Mappings:",
"color_strip.game_event.event_mappings.hint": "Override or add event-to-effect mappings for this source. These supplement the integration-level mappings.",
"color_strip.game_event.error.no_integration": "Please select a game integration.",
"value_source.type.game_event": "Game Event",
"value_source.type.game_event.desc": "Game metrics (health, ammo, mana) as 0-1 values",
"value_source.game_event.integration": "Game Integration:",
"value_source.game_event.integration.hint": "Select the game integration that provides events for this value source.",
"value_source.game_event.event_type": "Event Type:",
"value_source.game_event.event_type.hint": "The continuous game event to track (health, mana, ammo, etc.).",
"value_source.game_event.min_game_value": "Min Game Value:",
"value_source.game_event.min_game_value.hint": "Raw game value that maps to output 0.0.",
"value_source.game_event.max_game_value": "Max Game Value:",
"value_source.game_event.max_game_value.hint": "Raw game value that maps to output 1.0.",
"value_source.game_event.smoothing": "Smoothing:",
"value_source.game_event.smoothing.hint": "EMA smoothing factor. 0 = instant, higher = smoother transitions.",
"value_source.game_event.default_value": "Default Value:",
"value_source.game_event.default_value.hint": "Output value when no events received within timeout.",
"value_source.game_event.timeout": "Timeout (s):",
"value_source.game_event.timeout.hint": "Seconds of silence before reverting to the default value."
}
@@ -1915,5 +1915,91 @@
"donation.about_title": "О LedGrab",
"donation.about_opensource": "LedGrab — программа с открытым исходным кодом, бесплатная для использования и модификации.",
"donation.about_donate": "Поддержать разработку",
"donation.about_license": "Лицензия MIT"
"donation.about_license": "Лицензия MIT",
"streams.group.game": "Игровая интеграция",
"tree.group.game": "Игры",
"game_integration.section_title": "Игровые интеграции",
"section.empty.game_integrations": "Нет игровых интеграций. Нажмите +, чтобы создать.",
"game_integration.add": "Добавить игровую интеграцию",
"game_integration.edit": "Редактировать игровую интеграцию",
"game_integration.created": "Игровая интеграция создана",
"game_integration.updated": "Игровая интеграция обновлена",
"game_integration.deleted": "Игровая интеграция удалена",
"game_integration.confirm_delete": "Удалить эту игровую интеграцию?",
"game_integration.error.name_required": "Требуется имя",
"game_integration.error.save_failed": "Не удалось сохранить игровую интеграцию",
"game_integration.error.delete_failed": "Не удалось удалить игровую интеграцию",
"game_integration.error.save_first": "Сначала сохраните интеграцию для проверки соединения",
"game_integration.name": "Имя:",
"game_integration.name.hint": "Описательное имя для этой игровой интеграции",
"game_integration.description": "Описание:",
"game_integration.description.hint": "Необязательное описание назначения интеграции",
"game_integration.enabled": "Включено",
"game_integration.adapter_type": "Игра / Адаптер:",
"game_integration.adapter_type.hint": "Выберите тип игры или адаптера",
"game_integration.adapter_config": "Конфигурация адаптера",
"game_integration.no_config": "Конфигурация для этого адаптера не требуется.",
"game_integration.setup_instructions": "Инструкции по настройке",
"game_integration.setup_instructions.hint": "Следуйте этим шагам для настройки отправки данных из игры",
"game_integration.event_mappings": "Привязка событий",
"game_integration.event_mappings.hint": "Привяжите игровые события к LED-эффектам. Каждый тип события может вызывать свой визуальный эффект.",
"game_integration.mapping.add": "+ Добавить привязку",
"game_integration.mapping.event_type": "Событие",
"game_integration.mapping.effect_type": "Эффект",
"game_integration.mapping.color": "Цвет",
"game_integration.mapping.duration": "Длительность (мс)",
"game_integration.mapping.intensity": "Интенсивность",
"game_integration.mapping.priority": "Приоритет",
"game_integration.mapping.select_preset": "Загрузить пресет...",
"game_integration.preset.select": "Загрузить пресет...",
"game_integration.preset.fps_combat": "FPS Бой",
"game_integration.preset.moba_health": "MOBA Здоровье",
"game_integration.adapter": "Адаптер",
"game_integration.status": "Статус",
"game_integration.status.active": "Активна",
"game_integration.status.inactive": "Неактивна",
"game_integration.mappings": "Привязки",
"game_integration.events.title": "События в реальном времени",
"game_integration.events.waiting": "Ожидание событий...",
"game_integration.events.monitor": "Монитор событий",
"game_integration.test.button": "Тестировать соединение",
"game_integration.test.waiting": "Ожидание событий от игры...",
"game_integration.test.success": "Соединение успешно! Получены события.",
"game_integration.test.error": "Ошибка соединения",
"game_integration.test.timeout": "События не получены за отведённое время.",
"game_integration.auto_setup": "Автонастройка",
"game_integration.auto_setup.success": "Файл конфигурации успешно записан",
"game_integration.auto_setup.failed": "Автонастройка не удалась",
"game_integration.auto_setup.not_supported": "Этот адаптер не поддерживает автонастройку",
"game_integration.auto_setup.game_not_found": "Установка игры не найдена",
"game_integration.auto_setup.token_generated": "Токен авторизации был сгенерирован автоматически",
"game_integration.auto_setup.save_first": "Сначала сохраните интеграцию перед запуском автонастройки",
"color_strip.type.game_event": "Игровое событие",
"color_strip.type.game_event.desc": "LED-эффекты по игровым событиям",
"color_strip.game_event.integration": "Игровая интеграция:",
"color_strip.game_event.integration.hint": "Выберите игровую интеграцию, от которой поступают события.",
"color_strip.game_event.idle_color": "Цвет простоя:",
"color_strip.game_event.idle_color.hint": "Цвет LED, когда нет активных игровых событий.",
"color_strip.game_event.event_mappings": "Привязка событий:",
"color_strip.game_event.event_mappings.hint": "Переопределите или добавьте привязки событий к эффектам для этого источника.",
"color_strip.game_event.error.no_integration": "Выберите игровую интеграцию.",
"value_source.type.game_event": "Игровое событие",
"value_source.type.game_event.desc": "Игровые метрики (здоровье, патроны, мана) как значения 0-1",
"value_source.game_event.integration": "Игровая интеграция:",
"value_source.game_event.integration.hint": "Выберите игровую интеграцию для этого источника значений.",
"value_source.game_event.event_type": "Тип события:",
"value_source.game_event.event_type.hint": "Непрерывное игровое событие (здоровье, мана, патроны и т.д.).",
"value_source.game_event.min_game_value": "Мин. игровое значение:",
"value_source.game_event.min_game_value.hint": "Исходное игровое значение, соответствующее 0.0.",
"value_source.game_event.max_game_value": "Макс. игровое значение:",
"value_source.game_event.max_game_value.hint": "Исходное игровое значение, соответствующее 1.0.",
"value_source.game_event.smoothing": "Сглаживание:",
"value_source.game_event.smoothing.hint": "Коэффициент EMA-сглаживания. 0 = мгновенно, выше = плавнее.",
"value_source.game_event.default_value": "Значение по умолчанию:",
"value_source.game_event.default_value.hint": "Выходное значение, когда события не поступают в пределах таймаута.",
"value_source.game_event.timeout": "Таймаут (с):",
"value_source.game_event.timeout.hint": "Секунды тишины до возврата к значению по умолчанию."
}
@@ -1913,5 +1913,91 @@
"donation.about_title": "关于 LedGrab",
"donation.about_opensource": "LedGrab 是开源软件,可免费使用和修改。",
"donation.about_donate": "支持开发",
"donation.about_license": "MIT 许可证"
"donation.about_license": "MIT 许可证",
"streams.group.game": "游戏集成",
"tree.group.game": "游戏",
"game_integration.section_title": "游戏集成",
"section.empty.game_integrations": "暂无游戏集成。点击 + 创建。",
"game_integration.add": "添加游戏集成",
"game_integration.edit": "编辑游戏集成",
"game_integration.created": "游戏集成已创建",
"game_integration.updated": "游戏集成已更新",
"game_integration.deleted": "游戏集成已删除",
"game_integration.confirm_delete": "删除此游戏集成?",
"game_integration.error.name_required": "名称不能为空",
"game_integration.error.save_failed": "保存游戏集成失败",
"game_integration.error.delete_failed": "删除游戏集成失败",
"game_integration.error.save_first": "请先保存集成以测试连接",
"game_integration.name": "名称:",
"game_integration.name.hint": "为此游戏集成提供一个描述性名称",
"game_integration.description": "描述:",
"game_integration.description.hint": "可选描述此集成的用途",
"game_integration.enabled": "启用",
"game_integration.adapter_type": "游戏/适配器:",
"game_integration.adapter_type.hint": "选择此集成的游戏或适配器类型",
"game_integration.adapter_config": "适配器配置",
"game_integration.no_config": "此适配器无需配置。",
"game_integration.setup_instructions": "设置说明",
"game_integration.setup_instructions.hint": "按照以下步骤配置您的游戏向此集成发送数据",
"game_integration.event_mappings": "事件映射",
"game_integration.event_mappings.hint": "将游戏事件映射到 LED 效果。每种事件类型可触发不同的视觉效果。",
"game_integration.mapping.add": "+ 添加映射",
"game_integration.mapping.event_type": "事件",
"game_integration.mapping.effect_type": "效果",
"game_integration.mapping.color": "颜色",
"game_integration.mapping.duration": "持续时间 (毫秒)",
"game_integration.mapping.intensity": "强度",
"game_integration.mapping.priority": "优先级",
"game_integration.mapping.select_preset": "加载预设...",
"game_integration.preset.select": "加载预设...",
"game_integration.preset.fps_combat": "FPS 战斗",
"game_integration.preset.moba_health": "MOBA 生命值",
"game_integration.adapter": "适配器",
"game_integration.status": "状态",
"game_integration.status.active": "活跃",
"game_integration.status.inactive": "未激活",
"game_integration.mappings": "映射",
"game_integration.events.title": "实时事件",
"game_integration.events.waiting": "等待事件...",
"game_integration.events.monitor": "事件监控",
"game_integration.test.button": "测试连接",
"game_integration.test.waiting": "等待游戏事件...",
"game_integration.test.success": "连接成功!已收到事件。",
"game_integration.test.error": "连接错误",
"game_integration.test.timeout": "在超时期间内未收到事件。",
"game_integration.auto_setup": "自动配置",
"game_integration.auto_setup.success": "配置文件写入成功",
"game_integration.auto_setup.failed": "自动配置失败",
"game_integration.auto_setup.not_supported": "此适配器不支持自动配置",
"game_integration.auto_setup.game_not_found": "未找到游戏安装",
"game_integration.auto_setup.token_generated": "授权令牌已自动生成",
"game_integration.auto_setup.save_first": "请先保存集成,然后再运行自动配置",
"color_strip.type.game_event": "游戏事件",
"color_strip.type.game_event.desc": "由游戏事件触发的LED效果",
"color_strip.game_event.integration": "游戏集成:",
"color_strip.game_event.integration.hint": "选择为此源提供事件的游戏集成。",
"color_strip.game_event.idle_color": "空闲颜色:",
"color_strip.game_event.idle_color.hint": "没有活动游戏事件时的LED颜色。",
"color_strip.game_event.event_mappings": "事件映射:",
"color_strip.game_event.event_mappings.hint": "为此源覆盖或添加事件到效果的映射。这些补充集成级别的映射。",
"color_strip.game_event.error.no_integration": "请选择游戏集成。",
"value_source.type.game_event": "游戏事件",
"value_source.type.game_event.desc": "游戏指标(生命值、弹药、法力)作为0-1值",
"value_source.game_event.integration": "游戏集成:",
"value_source.game_event.integration.hint": "选择为此值源提供事件的游戏集成。",
"value_source.game_event.event_type": "事件类型:",
"value_source.game_event.event_type.hint": "要跟踪的持续游戏事件(生命值、法力、弹药等)。",
"value_source.game_event.min_game_value": "最小游戏值:",
"value_source.game_event.min_game_value.hint": "映射到输出0.0的原始游戏值。",
"value_source.game_event.max_game_value": "最大游戏值:",
"value_source.game_event.max_game_value.hint": "映射到输出1.0的原始游戏值。",
"value_source.game_event.smoothing": "平滑:",
"value_source.game_event.smoothing.hint": "EMA平滑系数。0 = 即时,越高越平滑。",
"value_source.game_event.default_value": "默认值:",
"value_source.game_event.default_value.hint": "在超时时间内未收到事件时的输出值。",
"value_source.game_event.timeout": "超时(秒):",
"value_source.game_event.timeout.hint": "恢复到默认值前的静默秒数。"
}
@@ -1,4 +1,4 @@
"""Automation and Condition data models."""
"""Automation and Rule data models."""
import logging
from dataclasses import dataclass, field
@@ -9,40 +9,30 @@ logger = logging.getLogger(__name__)
@dataclass
class Condition:
"""Base condition — polymorphic via condition_type discriminator."""
class Rule:
"""Base rule — polymorphic via rule_type discriminator."""
condition_type: str
rule_type: str
def to_dict(self) -> dict:
return {"condition_type": self.condition_type}
return {"rule_type": self.rule_type}
@classmethod
def from_dict(cls, data: dict) -> "Condition":
def from_dict(cls, data: dict) -> "Rule":
"""Factory: dispatch to the correct subclass via registry."""
ct = data.get("condition_type", "")
subcls = _CONDITION_MAP.get(ct)
# Support legacy "condition_type" key for migration
rt = data.get("rule_type") or data.get("condition_type", "")
subcls = _RULE_MAP.get(rt)
if subcls is None:
raise ValueError(f"Unknown condition type: {ct}")
raise ValueError(f"Unknown rule type: {rt}")
return subcls.from_dict(data)
@dataclass
class AlwaysCondition(Condition):
"""Always-true condition — automation activates unconditionally when enabled."""
condition_type: str = "always"
@classmethod
def from_dict(cls, data: dict) -> "AlwaysCondition":
return cls()
@dataclass
class ApplicationCondition(Condition):
class ApplicationRule(Rule):
"""Activate when specified applications are running or topmost."""
condition_type: str = "application"
rule_type: str = "application"
apps: List[str] = field(default_factory=list)
match_type: str = "running" # "running" | "topmost"
@@ -53,7 +43,7 @@ class ApplicationCondition(Condition):
return d
@classmethod
def from_dict(cls, data: dict) -> "ApplicationCondition":
def from_dict(cls, data: dict) -> "ApplicationRule":
return cls(
apps=data.get("apps", []),
match_type=data.get("match_type", "running"),
@@ -61,14 +51,14 @@ class ApplicationCondition(Condition):
@dataclass
class TimeOfDayCondition(Condition):
class TimeOfDayRule(Rule):
"""Activate during a specific time range (server local time).
Supports overnight ranges: if start_time > end_time, the range wraps
around midnight (e.g. 22:00 06:00).
"""
condition_type: str = "time_of_day"
rule_type: str = "time_of_day"
start_time: str = "00:00" # HH:MM
end_time: str = "23:59" # HH:MM
@@ -79,7 +69,7 @@ class TimeOfDayCondition(Condition):
return d
@classmethod
def from_dict(cls, data: dict) -> "TimeOfDayCondition":
def from_dict(cls, data: dict) -> "TimeOfDayRule":
return cls(
start_time=data.get("start_time", "00:00"),
end_time=data.get("end_time", "23:59"),
@@ -87,10 +77,10 @@ class TimeOfDayCondition(Condition):
@dataclass
class SystemIdleCondition(Condition):
class SystemIdleRule(Rule):
"""Activate based on system idle time (keyboard/mouse inactivity)."""
condition_type: str = "system_idle"
rule_type: str = "system_idle"
idle_minutes: int = 5
when_idle: bool = True # True = active when idle; False = active when NOT idle
@@ -101,7 +91,7 @@ class SystemIdleCondition(Condition):
return d
@classmethod
def from_dict(cls, data: dict) -> "SystemIdleCondition":
def from_dict(cls, data: dict) -> "SystemIdleRule":
return cls(
idle_minutes=data.get("idle_minutes", 5),
when_idle=data.get("when_idle", True),
@@ -109,10 +99,10 @@ class SystemIdleCondition(Condition):
@dataclass
class DisplayStateCondition(Condition):
class DisplayStateRule(Rule):
"""Activate based on display/monitor power state."""
condition_type: str = "display_state"
rule_type: str = "display_state"
state: str = "on" # "on" | "off"
def to_dict(self) -> dict:
@@ -121,17 +111,17 @@ class DisplayStateCondition(Condition):
return d
@classmethod
def from_dict(cls, data: dict) -> "DisplayStateCondition":
def from_dict(cls, data: dict) -> "DisplayStateRule":
return cls(
state=data.get("state", "on"),
)
@dataclass
class MQTTCondition(Condition):
class MQTTRule(Rule):
"""Activate based on an MQTT topic value."""
condition_type: str = "mqtt"
rule_type: str = "mqtt"
topic: str = ""
payload: str = ""
match_mode: str = "exact" # "exact" | "contains" | "regex"
@@ -144,7 +134,7 @@ class MQTTCondition(Condition):
return d
@classmethod
def from_dict(cls, data: dict) -> "MQTTCondition":
def from_dict(cls, data: dict) -> "MQTTRule":
return cls(
topic=data.get("topic", ""),
payload=data.get("payload", ""),
@@ -153,10 +143,10 @@ class MQTTCondition(Condition):
@dataclass
class WebhookCondition(Condition):
class WebhookRule(Rule):
"""Activate via an HTTP webhook call with a secret token."""
condition_type: str = "webhook"
rule_type: str = "webhook"
token: str = "" # auto-generated 128-bit hex secret
def to_dict(self) -> dict:
@@ -165,26 +155,26 @@ class WebhookCondition(Condition):
return d
@classmethod
def from_dict(cls, data: dict) -> "WebhookCondition":
def from_dict(cls, data: dict) -> "WebhookRule":
return cls(token=data.get("token", ""))
@dataclass
class StartupCondition(Condition):
class StartupRule(Rule):
"""Activate when the server starts — stays active while enabled."""
condition_type: str = "startup"
rule_type: str = "startup"
@classmethod
def from_dict(cls, data: dict) -> "StartupCondition":
def from_dict(cls, data: dict) -> "StartupRule":
return cls()
@dataclass
class HomeAssistantCondition(Condition):
class HomeAssistantRule(Rule):
"""Activate based on a Home Assistant entity state."""
condition_type: str = "home_assistant"
rule_type: str = "home_assistant"
ha_source_id: str = "" # references HomeAssistantSource
entity_id: str = "" # e.g. "binary_sensor.front_door"
state: str = "" # expected state value
@@ -199,7 +189,7 @@ class HomeAssistantCondition(Condition):
return d
@classmethod
def from_dict(cls, data: dict) -> "HomeAssistantCondition":
def from_dict(cls, data: dict) -> "HomeAssistantRule":
return cls(
ha_source_id=data.get("ha_source_id", ""),
entity_id=data.get("entity_id", ""),
@@ -208,42 +198,73 @@ class HomeAssistantCondition(Condition):
)
_CONDITION_MAP: Dict[str, Type[Condition]] = {
"always": AlwaysCondition,
"application": ApplicationCondition,
"time_of_day": TimeOfDayCondition,
"system_idle": SystemIdleCondition,
"display_state": DisplayStateCondition,
"mqtt": MQTTCondition,
"webhook": WebhookCondition,
"startup": StartupCondition,
"home_assistant": HomeAssistantCondition,
_RULE_MAP: Dict[str, Type[Rule]] = {
"application": ApplicationRule,
"time_of_day": TimeOfDayRule,
"system_idle": SystemIdleRule,
"display_state": DisplayStateRule,
"mqtt": MQTTRule,
"webhook": WebhookRule,
"startup": StartupRule,
"home_assistant": HomeAssistantRule,
# Legacy: "always" maps to StartupRule for migration
"always": StartupRule,
}
# ── Backward-compatible aliases (for imports in other modules during transition) ──
Condition = Rule
ApplicationCondition = ApplicationRule
TimeOfDayCondition = TimeOfDayRule
SystemIdleCondition = SystemIdleRule
DisplayStateCondition = DisplayStateRule
MQTTCondition = MQTTRule
WebhookCondition = WebhookRule
StartupCondition = StartupRule
HomeAssistantCondition = HomeAssistantRule
AlwaysCondition = StartupRule # "Always" removed — maps to Startup
@dataclass
class Automation:
"""Automation that activates a scene preset based on conditions."""
"""Automation that activates a scene preset based on rules."""
id: str
name: str
enabled: bool
condition_logic: str # "or" | "and"
conditions: List[Condition]
scene_preset_id: Optional[str] # scene to activate when conditions are met
rule_logic: str # "or" | "and"
rules: List[Rule]
scene_preset_id: Optional[str] # scene to activate when rules are met
deactivation_mode: str # "none" | "revert" | "fallback_scene"
deactivation_scene_preset_id: Optional[str] # scene for fallback_scene mode
created_at: datetime
updated_at: datetime
tags: List[str] = field(default_factory=list)
# Backward-compatible property aliases
@property
def condition_logic(self) -> str:
return self.rule_logic
@condition_logic.setter
def condition_logic(self, value: str) -> None:
self.rule_logic = value
@property
def conditions(self) -> List[Rule]:
return self.rules
@conditions.setter
def conditions(self, value: List[Rule]) -> None:
self.rules = value
def to_dict(self) -> dict:
return {
"id": self.id,
"name": self.name,
"enabled": self.enabled,
"condition_logic": self.condition_logic,
"conditions": [c.to_dict() for c in self.conditions],
"rule_logic": self.rule_logic,
"rules": [r.to_dict() for r in self.rules],
"scene_preset_id": self.scene_preset_id,
"deactivation_mode": self.deactivation_mode,
"deactivation_scene_preset_id": self.deactivation_scene_preset_id,
@@ -254,20 +275,26 @@ class Automation:
@classmethod
def from_dict(cls, data: dict) -> "Automation":
conditions = []
for c_data in data.get("conditions", []):
rules = []
# Support legacy "conditions" key for migration
raw_rules = data.get("rules") or data.get("conditions", [])
for r_data in raw_rules:
try:
conditions.append(Condition.from_dict(c_data))
rule = Rule.from_dict(r_data)
# Skip "always" rules during migration (they're redundant)
if r_data.get("rule_type") == "always" or r_data.get("condition_type") == "always":
logger.info("Migrating 'always' condition to startup rule")
rule = StartupRule()
rules.append(rule)
except ValueError as e:
logger.warning("Skipping unknown condition type on load: %s", e)
pass # skip unknown condition types on load
logger.warning("Skipping unknown rule type on load: %s", e)
return cls(
id=data["id"],
name=data["name"],
enabled=data.get("enabled", True),
condition_logic=data.get("condition_logic", "or"),
conditions=conditions,
rule_logic=data.get("rule_logic") or data.get("condition_logic", "or"),
rules=rules,
scene_preset_id=data.get("scene_preset_id"),
deactivation_mode=data.get("deactivation_mode", "none"),
deactivation_scene_preset_id=data.get("deactivation_scene_preset_id"),
@@ -4,7 +4,7 @@ import uuid
from datetime import datetime, timezone
from typing import List, Optional
from wled_controller.storage.automation import Automation, Condition
from wled_controller.storage.automation import Automation, Rule
from wled_controller.storage.base_sqlite_store import BaseSqliteStore
from wled_controller.storage.database import Database
from wled_controller.utils import get_logger
@@ -28,13 +28,22 @@ class AutomationStore(BaseSqliteStore[Automation]):
self,
name: str,
enabled: bool = True,
condition_logic: str = "or",
conditions: Optional[List[Condition]] = None,
rule_logic: str = "or",
rules: Optional[List[Rule]] = None,
scene_preset_id: Optional[str] = None,
deactivation_mode: str = "none",
deactivation_scene_preset_id: Optional[str] = None,
tags: Optional[List[str]] = None,
# Legacy parameter aliases
condition_logic: Optional[str] = None,
conditions: Optional[List[Rule]] = None,
) -> Automation:
# Support legacy parameter names
if condition_logic is not None and rule_logic == "or":
rule_logic = condition_logic
if conditions is not None and rules is None:
rules = conditions
for a in self._items.values():
if a.name == name:
raise ValueError(f"Automation with name '{name}' already exists")
@@ -46,8 +55,8 @@ class AutomationStore(BaseSqliteStore[Automation]):
id=automation_id,
name=name,
enabled=enabled,
condition_logic=condition_logic,
conditions=conditions or [],
rule_logic=rule_logic,
rules=rules or [],
scene_preset_id=scene_preset_id,
deactivation_mode=deactivation_mode,
deactivation_scene_preset_id=deactivation_scene_preset_id,
@@ -66,13 +75,22 @@ class AutomationStore(BaseSqliteStore[Automation]):
automation_id: str,
name: Optional[str] = None,
enabled: Optional[bool] = None,
condition_logic: Optional[str] = None,
conditions: Optional[List[Condition]] = None,
rule_logic: Optional[str] = None,
rules: Optional[List[Rule]] = None,
scene_preset_id: str = "__unset__",
deactivation_mode: Optional[str] = None,
deactivation_scene_preset_id: str = "__unset__",
tags: Optional[List[str]] = None,
# Legacy parameter aliases
condition_logic: Optional[str] = None,
conditions: Optional[List[Rule]] = None,
) -> Automation:
# Support legacy parameter names
if condition_logic is not None and rule_logic is None:
rule_logic = condition_logic
if conditions is not None and rules is None:
rules = conditions
automation = self.get(automation_id)
if name is not None:
@@ -80,16 +98,18 @@ class AutomationStore(BaseSqliteStore[Automation]):
automation.name = name
if enabled is not None:
automation.enabled = enabled
if condition_logic is not None:
automation.condition_logic = condition_logic
if conditions is not None:
automation.conditions = conditions
if rule_logic is not None:
automation.rule_logic = rule_logic
if rules is not None:
automation.rules = rules
if scene_preset_id != "__unset__":
automation.scene_preset_id = None if scene_preset_id == "" else scene_preset_id
if deactivation_mode is not None:
automation.deactivation_mode = deactivation_mode
if deactivation_scene_preset_id != "__unset__":
automation.deactivation_scene_preset_id = None if deactivation_scene_preset_id == "" else deactivation_scene_preset_id
automation.deactivation_scene_preset_id = (
None if deactivation_scene_preset_id == "" else deactivation_scene_preset_id
)
if tags is not None:
automation.tags = tags
@@ -1653,6 +1653,96 @@ class KeyColorsColorStripSource(ColorStripSource):
)
@dataclass
class GameEventColorStripSource(ColorStripSource):
"""Color strip source that renders LED effects in response to game events.
Subscribes to a GameEventBus via a game integration and renders visual
effects (flash, pulse, sweep, color_shift, breathing) when matching events
arrive. When idle, outputs the configured idle_color.
LED count auto-sizes from the connected device when led_count == 0.
"""
game_integration_id: str = ""
idle_color: BindableColor = field(default_factory=lambda: BindableColor([0, 0, 0]))
event_mappings: List[dict] = field(default_factory=list)
led_count: int = 0
@property
def sharable(self) -> bool:
return False
def to_dict(self) -> dict:
d = super().to_dict()
d["game_integration_id"] = self.game_integration_id
d["idle_color"] = self.idle_color.to_dict()
d["event_mappings"] = [dict(m) for m in self.event_mappings]
d["led_count"] = self.led_count
return d
@classmethod
def from_dict(cls, data: dict) -> "GameEventColorStripSource":
common = _parse_css_common(data)
raw_mappings = data.get("event_mappings")
return cls(
**common,
source_type="game_event",
game_integration_id=data.get("game_integration_id") or "",
idle_color=BindableColor.from_raw(
data.get("idle_color"),
default=[0, 0, 0],
),
event_mappings=raw_mappings if isinstance(raw_mappings, list) else [],
led_count=data.get("led_count") or 0,
)
@classmethod
def create_from_kwargs(
cls,
*,
id: str,
name: str,
source_type: str,
created_at: datetime,
updated_at: datetime,
description=None,
clock_id=None,
tags=None,
game_integration_id=None,
idle_color=None,
event_mappings=None,
led_count=None,
**_kwargs,
):
return cls(
id=id,
name=name,
source_type="game_event",
created_at=created_at,
updated_at=updated_at,
description=description,
clock_id=clock_id,
tags=tags or [],
game_integration_id=game_integration_id or "",
idle_color=BindableColor.from_raw(idle_color, default=[0, 0, 0]),
event_mappings=event_mappings if isinstance(event_mappings, list) else [],
led_count=led_count or 0,
)
def apply_update(self, **kwargs) -> None:
if kwargs.get("game_integration_id") is not None:
self.game_integration_id = kwargs["game_integration_id"]
if kwargs.get("idle_color") is not None:
self.idle_color = self.idle_color.apply_update(kwargs["idle_color"])
if kwargs.get("event_mappings") is not None:
raw = kwargs["event_mappings"]
if isinstance(raw, list):
self.event_mappings = raw
if kwargs.get("led_count") is not None:
self.led_count = kwargs["led_count"]
# -- Source type registry --
# Maps source_type string to its subclass for factory dispatch.
_SOURCE_TYPE_MAP: Dict[str, Type[ColorStripSource]] = {
@@ -1672,4 +1762,5 @@ _SOURCE_TYPE_MAP: Dict[str, Type[ColorStripSource]] = {
"processed": ProcessedColorStripSource,
"weather": WeatherColorStripSource,
"key_colors": KeyColorsColorStripSource,
"game_event": GameEventColorStripSource,
}
@@ -56,6 +56,7 @@ _ENTITY_TABLES = [
"weather_sources",
"assets",
"home_assistant_sources",
"game_integrations",
]
@@ -0,0 +1,169 @@
"""Game integration configuration data models.
Defines the GameIntegrationConfig dataclass and EventMapping dataclass
for persisting game integration settings (adapter type, event mappings,
per-integration config).
"""
import uuid
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any, List, Optional
@dataclass
class EventMapping:
"""Maps a standard game event type to a visual effect.
Attributes:
event_type: Standard event type from the vocabulary (e.g. "health", "kill").
effect: Effect name to trigger (e.g. "flash", "pulse", "gradient").
color: RGB color as [R, G, B] (0-255 each).
duration_ms: Effect duration in milliseconds.
intensity: Effect intensity 0.0-1.0.
priority: Priority for effect stacking (higher wins).
"""
event_type: str
effect: str = "flash"
color: List[int] = field(default_factory=lambda: [255, 0, 0])
duration_ms: int = 500
intensity: float = 1.0
priority: int = 0
def to_dict(self) -> dict:
return {
"event_type": self.event_type,
"effect": self.effect,
"color": list(self.color),
"duration_ms": self.duration_ms,
"intensity": self.intensity,
"priority": self.priority,
}
@classmethod
def from_dict(cls, data: dict) -> "EventMapping":
return cls(
event_type=data["event_type"],
effect=data.get("effect", "flash"),
color=list(data.get("color", [255, 0, 0])),
duration_ms=data.get("duration_ms", 500),
intensity=data.get("intensity", 1.0),
priority=data.get("priority", 0),
)
@dataclass
class GameIntegrationConfig:
"""Persistent configuration for a game integration.
Attributes:
id: Unique identifier (gi_<8hex>).
name: Human-readable name.
adapter_type: Registered adapter type string.
enabled: Whether this integration is active.
adapter_config: Adapter-specific settings (secrets, mappings, etc.).
event_mappings: List of event-to-effect mappings.
created_at: Creation timestamp.
updated_at: Last modification timestamp.
description: Optional description.
tags: User-defined tags.
"""
id: str
name: str
adapter_type: str
enabled: bool
adapter_config: dict[str, Any]
event_mappings: List[EventMapping]
created_at: datetime
updated_at: datetime
description: Optional[str] = None
tags: List[str] = field(default_factory=list)
def to_dict(self) -> dict:
return {
"id": self.id,
"name": self.name,
"adapter_type": self.adapter_type,
"enabled": self.enabled,
"adapter_config": dict(self.adapter_config),
"event_mappings": [m.to_dict() for m in self.event_mappings],
"created_at": self.created_at.isoformat(),
"updated_at": self.updated_at.isoformat(),
"description": self.description,
"tags": list(self.tags),
}
@classmethod
def from_dict(cls, data: dict) -> "GameIntegrationConfig":
mappings = [EventMapping.from_dict(m) for m in data.get("event_mappings", [])]
return cls(
id=data["id"],
name=data["name"],
adapter_type=data["adapter_type"],
enabled=data.get("enabled", True),
adapter_config=data.get("adapter_config", {}),
event_mappings=mappings,
created_at=(
datetime.fromisoformat(data["created_at"])
if isinstance(data.get("created_at"), str)
else data.get("created_at", datetime.now(timezone.utc))
),
updated_at=(
datetime.fromisoformat(data["updated_at"])
if isinstance(data.get("updated_at"), str)
else data.get("updated_at", datetime.now(timezone.utc))
),
description=data.get("description"),
tags=data.get("tags", []),
)
@staticmethod
def create_from_kwargs(
name: str,
adapter_type: str,
enabled: bool = True,
adapter_config: Optional[dict[str, Any]] = None,
event_mappings: Optional[List[EventMapping]] = None,
description: Optional[str] = None,
tags: Optional[List[str]] = None,
) -> "GameIntegrationConfig":
"""Factory method to create a new config with generated ID and timestamps."""
now = datetime.now(timezone.utc)
return GameIntegrationConfig(
id=f"gi_{uuid.uuid4().hex[:8]}",
name=name,
adapter_type=adapter_type,
enabled=enabled,
adapter_config=adapter_config or {},
event_mappings=event_mappings or [],
created_at=now,
updated_at=now,
description=description,
tags=tags or [],
)
def apply_update(
self,
name: Optional[str] = None,
adapter_type: Optional[str] = None,
enabled: Optional[bool] = None,
adapter_config: Optional[dict[str, Any]] = None,
event_mappings: Optional[List[EventMapping]] = None,
description: Optional[str] = None,
tags: Optional[List[str]] = None,
) -> "GameIntegrationConfig":
"""Return a new config with updated fields (immutable update)."""
return GameIntegrationConfig(
id=self.id,
name=name if name is not None else self.name,
adapter_type=adapter_type if adapter_type is not None else self.adapter_type,
enabled=enabled if enabled is not None else self.enabled,
adapter_config=adapter_config if adapter_config is not None else self.adapter_config,
event_mappings=event_mappings if event_mappings is not None else self.event_mappings,
created_at=self.created_at,
updated_at=datetime.now(timezone.utc),
description=description if description is not None else self.description,
tags=tags if tags is not None else self.tags,
)
@@ -0,0 +1,139 @@
"""Game integration configuration storage using SQLite.
Provides CRUD operations for GameIntegrationConfig entities with
name uniqueness validation and write-through caching.
"""
from typing import Any, List, Optional
from wled_controller.storage.base_sqlite_store import BaseSqliteStore
from wled_controller.storage.database import Database
from wled_controller.storage.game_integration import EventMapping, GameIntegrationConfig
from wled_controller.utils import get_logger
logger = get_logger(__name__)
class GameIntegrationStore(BaseSqliteStore[GameIntegrationConfig]):
"""Storage for game integration configurations.
All configs are persisted to the database with write-through caching.
"""
_table_name = "game_integrations"
_entity_name = "Game integration"
_version = "1.0.0"
def __init__(self, db: Database) -> None:
super().__init__(db, GameIntegrationConfig.from_dict)
# Backward-compatible aliases
get_all_integrations = BaseSqliteStore.get_all
get_integration = BaseSqliteStore.get
delete_integration = BaseSqliteStore.delete
def create_integration(
self,
name: str,
adapter_type: str,
enabled: bool = True,
adapter_config: Optional[dict[str, Any]] = None,
event_mappings: Optional[List[EventMapping]] = None,
description: Optional[str] = None,
tags: Optional[List[str]] = None,
) -> GameIntegrationConfig:
"""Create a new game integration config.
Args:
name: Human-readable name (must be unique).
adapter_type: Registered adapter type string.
enabled: Whether integration is active.
adapter_config: Adapter-specific settings.
event_mappings: Event-to-effect mappings.
description: Optional description.
tags: User-defined tags.
Returns:
The newly created config.
Raises:
ValueError: If name is empty or already taken.
"""
with self._lock:
self._check_name_unique(name)
config = GameIntegrationConfig.create_from_kwargs(
name=name,
adapter_type=adapter_type,
enabled=enabled,
adapter_config=adapter_config,
event_mappings=event_mappings,
description=description,
tags=tags,
)
self._items[config.id] = config
self._save_item(config.id, config)
logger.info(f"Created game integration: {name} ({config.id})")
return config
def update_integration(
self,
integration_id: str,
name: Optional[str] = None,
adapter_type: Optional[str] = None,
enabled: Optional[bool] = None,
adapter_config: Optional[dict[str, Any]] = None,
event_mappings: Optional[List[EventMapping]] = None,
description: Optional[str] = None,
tags: Optional[List[str]] = None,
) -> GameIntegrationConfig:
"""Update an existing game integration config.
Args:
integration_id: Config ID to update.
name: New name (must be unique if changed).
adapter_type: New adapter type.
enabled: New enabled state.
adapter_config: New adapter-specific settings.
event_mappings: New event-to-effect mappings.
description: New description.
tags: New tags.
Returns:
The updated config.
Raises:
EntityNotFoundError: If integration_id not found.
ValueError: If new name conflicts with an existing config.
"""
with self._lock:
existing = self.get(integration_id)
if name is not None:
self._check_name_unique(name, exclude_id=integration_id)
updated = existing.apply_update(
name=name,
adapter_type=adapter_type,
enabled=enabled,
adapter_config=adapter_config,
event_mappings=event_mappings,
description=description,
tags=tags,
)
self._items[integration_id] = updated
self._save_item(integration_id, updated)
logger.info(f"Updated game integration: {integration_id}")
return updated
def get_references(self, integration_id: str) -> List[str]:
"""Return names of entities that reference this integration.
Currently game integrations are not referenced by other entities,
but this method is provided for future cascade prevention.
"""
return []
@@ -481,6 +481,55 @@ VALID_SYSTEM_METRICS = (
)
@dataclass
class GameEventValueSource(ValueSource):
"""Value source driven by game events via the GameEventBus.
Exposes game metrics (health, ammo, mana, etc.) as 0.0-1.0 scalar values.
Incoming raw game values are normalized using min/max mapping, with optional
EMA smoothing for smooth transitions. Reverts to default_value when no
events are received within the timeout period.
"""
game_integration_id: str = "" # references a GameIntegration config
event_type: str = "health" # standard event vocabulary type
min_game_value: float = 0.0 # raw game value mapped to 0.0
max_game_value: float = 100.0 # raw game value mapped to 1.0
smoothing: float = 0.0 # EMA smoothing factor (0.0-1.0)
default_value: float = 0.5 # value when timed out or no events
timeout: float = 5.0 # seconds before reverting to default
def to_dict(self) -> dict:
d = super().to_dict()
d["game_integration_id"] = self.game_integration_id
d["event_type"] = self.event_type
d["min_game_value"] = self.min_game_value
d["max_game_value"] = self.max_game_value
d["smoothing"] = self.smoothing
d["default_value"] = self.default_value
d["timeout"] = self.timeout
return d
@classmethod
def from_dict(cls, data: dict) -> "GameEventValueSource":
common = _parse_common_fields(data)
return cls(
**common,
source_type="game_event",
game_integration_id=data.get("game_integration_id") or "",
event_type=data.get("event_type") or "health",
min_game_value=float(data.get("min_game_value") or 0.0),
max_game_value=float(
data.get("max_game_value") if data.get("max_game_value") is not None else 100.0
),
smoothing=float(data.get("smoothing") or 0.0),
default_value=float(
data.get("default_value") if data.get("default_value") is not None else 0.5
),
timeout=float(data.get("timeout") if data.get("timeout") is not None else 5.0),
)
@dataclass
class SystemMetricsValueSource(ValueSource):
"""Value source that reads system hardware metrics.
@@ -545,4 +594,5 @@ _VALUE_SOURCE_MAP: Dict[str, Type[ValueSource]] = {
"gradient_map": GradientMapValueSource,
"css_extract": CSSExtractValueSource,
"system_metrics": SystemMetricsValueSource,
"game_event": GameEventValueSource,
}
@@ -216,6 +216,7 @@
{% include 'modals/ha-light-editor.html' %}
{% include 'modals/asset-upload.html' %}
{% include 'modals/asset-editor.html' %}
{% include 'modals/game-integration-editor.html' %}
{% include 'modals/settings.html' %}
{% include 'partials/tutorial-overlay.html' %}
@@ -24,7 +24,7 @@
<label data-i18n="automations.enabled">Enabled:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="automations.enabled.hint">Disabled automations won't activate even when conditions are met</small>
<small class="input-hint" style="display:none" data-i18n="automations.enabled.hint">Disabled automations won't activate even when rules are met</small>
<label class="settings-toggle">
<input type="checkbox" id="automation-editor-enabled" checked>
<span class="settings-toggle-slider"></span>
@@ -33,25 +33,25 @@
<div class="form-group">
<div class="label-row">
<label for="automation-editor-logic" data-i18n="automations.condition_logic">Condition Logic:</label>
<label for="automation-editor-logic" data-i18n="automations.rule_logic">Rule Logic:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="automations.condition_logic.hint">How multiple conditions are combined: ANY (OR) or ALL (AND)</small>
<small class="input-hint" style="display:none" data-i18n="automations.rule_logic.hint">How multiple rules are combined: ANY (OR) or ALL (AND)</small>
<select id="automation-editor-logic">
<option value="or" data-i18n="automations.condition_logic.or">Any condition (OR)</option>
<option value="and" data-i18n="automations.condition_logic.and">All conditions (AND)</option>
<option value="or" data-i18n="automations.rule_logic.or">Any rule (OR)</option>
<option value="and" data-i18n="automations.rule_logic.and">All rules (AND)</option>
</select>
</div>
<div class="form-group">
<div class="label-row">
<label data-i18n="automations.conditions">Conditions:</label>
<label data-i18n="automations.rules">Rules:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="automations.conditions.hint">Rules that determine when this automation activates</small>
<div id="automation-conditions-list"></div>
<button type="button" class="btn btn-secondary btn-sm" onclick="addAutomationCondition()" style="margin-top: 6px;">
+ <span data-i18n="automations.conditions.add">Add Condition</span>
<small class="input-hint" style="display:none" data-i18n="automations.rules.hint">Rules that determine when this automation activates</small>
<div id="automation-rules-list"></div>
<button type="button" class="btn btn-secondary btn-sm" onclick="addAutomationRule()" style="margin-top: 6px;">
+ <span data-i18n="automations.rules.add">Add Rule</span>
</button>
</div>
@@ -60,7 +60,7 @@
<label for="automation-scene-id" data-i18n="automations.scene">Scene:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="automations.scene.hint">Scene preset to activate when conditions are met</small>
<small class="input-hint" style="display:none" data-i18n="automations.scene.hint">Scene preset to activate when rules are met</small>
<select id="automation-scene-id"></select>
</div>
@@ -69,7 +69,7 @@
<label for="automation-deactivation-mode" data-i18n="automations.deactivation_mode">Deactivation:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="automations.deactivation_mode.hint">What happens when conditions stop matching</small>
<small class="input-hint" style="display:none" data-i18n="automations.deactivation_mode.hint">What happens when rules stop matching</small>
<select id="automation-deactivation-mode">
<option value="none" data-i18n="automations.deactivation_mode.none">None — keep current state</option>
<option value="revert" data-i18n="automations.deactivation_mode.revert">Revert to previous state</option>
@@ -38,6 +38,7 @@
<option value="weather" data-i18n="color_strip.type.weather">Weather</option>
<option value="processed" data-i18n="color_strip.type.processed">Processed</option>
<option value="key_colors" data-i18n="color_strip.type.key_colors">Key Colors</option>
<option value="game_event" data-i18n="color_strip.type.game_event">Game Event</option>
</select>
</div>
@@ -689,6 +690,46 @@
</div>
</div>
<!-- Game Event section -->
<div id="css-editor-game-event-section" style="display:none">
<div class="form-group">
<div class="label-row">
<label for="css-editor-game-integration" data-i18n="color_strip.game_event.integration">Game Integration:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="color_strip.game_event.integration.hint">Select the game integration that provides events for this source.</small>
<select id="css-editor-game-integration">
<option value=""></option>
</select>
</div>
<div class="form-group">
<div class="label-row">
<label data-i18n="color_strip.game_event.idle_color">Idle Color:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="color_strip.game_event.idle_color.hint">LED color when no game events are active.</small>
<div id="css-editor-game-event-idle-color-container"></div>
</div>
<div class="form-group">
<div class="label-row">
<label data-i18n="color_strip.game_event.event_mappings">Event Mappings:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="color_strip.game_event.event_mappings.hint">Override or add event-to-effect mappings for this source. These supplement the integration-level mappings.</small>
<div class="gi-mapping-preset-row">
<select id="css-editor-ge-mapping-preset" onchange="onCSSGameMappingPresetChange()">
<option value="" data-i18n="game_integration.preset.select">Load preset...</option>
<option value="fps_combat" data-i18n="game_integration.preset.fps_combat">FPS Combat</option>
<option value="moba_health" data-i18n="game_integration.preset.moba_health">MOBA Health</option>
</select>
</div>
<div id="css-editor-ge-mappings-list" class="gi-mappings-container"></div>
<button type="button" class="btn btn-secondary btn-sm" onclick="addCSSGameMapping()" style="margin-top:6px">
<span data-i18n="game_integration.mapping.add">+ Add Mapping</span>
</button>
</div>
</div>
<!-- Shared LED count field -->
<div id="css-editor-led-count-group" class="form-group">
<div class="label-row">
@@ -0,0 +1,112 @@
<div id="game-integration-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="gi-title">
<div class="modal-content modal-lg">
<div class="modal-header">
<h2 id="gi-title" data-i18n="game_integration.add">Add Game Integration</h2>
<button class="modal-close-btn" onclick="closeGameIntegrationModal()" data-i18n-aria-label="aria.close">&times;</button>
</div>
<div class="modal-body">
<input type="hidden" id="gi-id">
<div id="gi-error" class="modal-error" style="display:none"></div>
<!-- Name + Tags -->
<div class="form-group">
<div class="label-row">
<label for="gi-name" data-i18n="game_integration.name">Name:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="game_integration.name.hint">A descriptive name for this game integration</small>
<input type="text" id="gi-name" required>
<div id="gi-tags-container"></div>
</div>
<!-- Description -->
<div class="form-group">
<div class="label-row">
<label for="gi-description" data-i18n="game_integration.description">Description:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="game_integration.description.hint">Optional description of what this integration does</small>
<input type="text" id="gi-description">
</div>
<!-- Enabled -->
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" id="gi-enabled" checked>
<span data-i18n="game_integration.enabled">Enabled</span>
</label>
</div>
<!-- Game / Adapter picker -->
<div class="form-group">
<div class="label-row">
<label for="gi-adapter-type" data-i18n="game_integration.adapter_type">Game / Adapter:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="game_integration.adapter_type.hint">Select the game or adapter type for this integration</small>
<select id="gi-adapter-type"></select>
</div>
<!-- Adapter config (auto-generated) -->
<div class="form-group">
<div class="label-row">
<label data-i18n="game_integration.adapter_config">Adapter Configuration</label>
</div>
<div id="gi-adapter-config-fields"></div>
</div>
<!-- Setup instructions + Auto Setup buttons -->
<div id="gi-setup-instructions-btn-wrapper" style="display:none">
<button type="button" class="btn btn-secondary btn-sm" onclick="openSetupInstructions()" data-i18n="game_integration.setup_instructions">Setup Instructions</button>
<button type="button" id="gi-auto-setup-btn" class="btn btn-primary btn-sm" onclick="autoSetupGameIntegration()" style="display:none" data-i18n="game_integration.auto_setup">Auto Setup</button>
</div>
<!-- Event Mapping Editor -->
<div class="form-group">
<div class="label-row">
<label data-i18n="game_integration.event_mappings">Event Mappings</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="game_integration.event_mappings.hint">Map game events to LED effects. Each event type can trigger a different visual effect.</small>
<div class="gi-mapping-toolbar">
<select id="gi-mapping-preset" onchange="onMappingPresetChange()">
<option value="" data-i18n="game_integration.preset.select">Load preset...</option>
<option value="fps_combat" data-i18n="game_integration.preset.fps_combat">FPS Combat</option>
<option value="moba_health" data-i18n="game_integration.preset.moba_health">MOBA Health</option>
</select>
<button class="btn btn-secondary btn-sm" onclick="addGameMapping()" data-i18n="game_integration.mapping.add">+ Add Mapping</button>
</div>
<div id="gi-mappings-list" class="gi-mappings-list"></div>
</div>
<!-- Live Event Monitor -->
<div class="form-group">
<div class="label-row">
<label data-i18n="game_integration.events.title">Live Events</label>
</div>
<div id="gi-event-feed" class="gi-event-feed"></div>
</div>
<!-- Connection Test -->
<div class="form-group">
<button class="btn btn-secondary" onclick="testGameConnection()" data-i18n="game_integration.test.button">Test Connection</button>
<div id="gi-test-panel" style="display:none" class="gi-test-panel"></div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-icon btn-secondary" onclick="closeGameIntegrationModal()" title="Cancel" data-i18n-title="settings.button.cancel" data-i18n-aria-label="aria.cancel">&#x2715;</button>
<button class="btn btn-icon btn-primary" onclick="saveGameIntegration()" title="Save" data-i18n-title="settings.button.save" data-i18n-aria-label="aria.save">&#x2713;</button>
</div>
</div>
</div>
<!-- Setup Instructions Overlay (full-screen, same pattern as release notes) -->
<div id="gi-setup-overlay" class="log-overlay" style="display:none;">
<button class="log-overlay-close" onclick="closeSetupInstructions()" title="Close" data-i18n-aria-label="aria.close">&#x2715;</button>
<div class="log-overlay-toolbar">
<h3 id="gi-setup-overlay-title" data-i18n="game_integration.setup_instructions">Setup Instructions</h3>
</div>
<div id="gi-setup-overlay-content" class="release-notes-content"></div>
</div>
@@ -535,6 +535,78 @@
</div>
</div>
<!-- Game Event value source fields -->
<div id="value-source-game-event-section" style="display:none">
<div class="form-group">
<div class="label-row">
<label for="value-source-game-integration" data-i18n="value_source.game_event.integration">Game Integration:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="value_source.game_event.integration.hint">Select the game integration that provides events for this value source.</small>
<select id="value-source-game-integration">
<option value=""></option>
</select>
</div>
<div class="form-group">
<div class="label-row">
<label for="value-source-game-event-type" data-i18n="value_source.game_event.event_type">Event Type:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="value_source.game_event.event_type.hint">The continuous game event to track (health, mana, ammo, etc.).</small>
<select id="value-source-game-event-type">
<option value="health">health</option>
<option value="armor">armor</option>
<option value="mana">mana</option>
<option value="ammo">ammo</option>
<option value="stamina">stamina</option>
<option value="shield">shield</option>
<option value="score">score</option>
<option value="gold">gold</option>
<option value="xp">xp</option>
<option value="level">level</option>
</select>
</div>
<div class="form-group">
<div class="label-row">
<label for="value-source-ge-min"><span data-i18n="value_source.game_event.min_game_value">Min Game Value:</span> <span id="value-source-ge-min-display">0</span></label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="value_source.game_event.min_game_value.hint">Raw game value that maps to output 0.0.</small>
<input type="number" id="value-source-ge-min" step="any" value="0">
</div>
<div class="form-group">
<div class="label-row">
<label for="value-source-ge-max"><span data-i18n="value_source.game_event.max_game_value">Max Game Value:</span> <span id="value-source-ge-max-display">100</span></label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="value_source.game_event.max_game_value.hint">Raw game value that maps to output 1.0.</small>
<input type="number" id="value-source-ge-max" step="any" value="100">
</div>
<div class="form-group">
<label for="value-source-ge-smoothing"><span data-i18n="value_source.game_event.smoothing">Smoothing:</span> <span id="value-source-ge-smoothing-display">0</span></label>
<input type="range" id="value-source-ge-smoothing" min="0" max="0.99" step="0.01" value="0"
oninput="document.getElementById('value-source-ge-smoothing-display').textContent = this.value">
</div>
<div class="form-group">
<div class="label-row">
<label for="value-source-ge-default"><span data-i18n="value_source.game_event.default_value">Default Value:</span> <span id="value-source-ge-default-display">0.5</span></label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="value_source.game_event.default_value.hint">Output value when no events received within timeout.</small>
<input type="range" id="value-source-ge-default" min="0" max="1" step="0.01" value="0.5"
oninput="document.getElementById('value-source-ge-default-display').textContent = this.value">
</div>
<div class="form-group">
<div class="label-row">
<label for="value-source-ge-timeout"><span data-i18n="value_source.game_event.timeout">Timeout (s):</span> <span id="value-source-ge-timeout-display">5.0</span></label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="value_source.game_event.timeout.hint">Seconds of silence before reverting to the default value.</small>
<input type="range" id="value-source-ge-timeout" min="1" max="60" step="0.5" value="5"
oninput="document.getElementById('value-source-ge-timeout-display').textContent = parseFloat(this.value).toFixed(1)">
</div>
</div>
<!-- Shared adaptive output range (shown for adaptive and daylight types) -->
<div id="value-source-adaptive-range-section" style="display:none">
<div class="form-group">