feat: game integration system
Receive real-time events from games (CS2, Dota 2, LoL, etc.) and drive LED effects through the existing color strip and value source pipelines. Core: - GameEventBus (thread-safe pub/sub) with standardized 23-type event vocabulary - GameAdapter ABC + AdapterRegistry + MappingAdapter (YAML-driven) - Built-in adapters: CS2 GSI, Dota 2 GSI, LoL Live Client, Generic Webhook - Community YAML adapters: Minecraft, Valorant, Rocket League - GameEventColorStripStream with 5 effects (flash/pulse/sweep/color_shift/breathing) - GameEventValueSource with EMA smoothing and timeout - 4 built-in effect presets (FPS Combat, MOBA Health, Racing, Generic Alert) - Auto-setup for Valve GSI games (Steam path detection, cfg file writing) - Demo capture engine exposed to non-demo mode Frontend: - Game tab in Streams tree navigation with integration cards - Game integration editor modal with adapter picker, config fields, event mappings - game_event source type in CSS and ValueSource editors - Setup instructions overlay (markdown rendered) - Live event monitor and connection test API: - Full CRUD for game integrations - Event ingestion endpoint (adapter-level auth) - Adapter metadata, presets, auto-setup, status/diagnostics endpoints
This commit is contained in:
@@ -26,6 +26,7 @@ from .routes.weather_sources import router as weather_sources_router
|
||||
from .routes.update import router as update_router
|
||||
from .routes.assets import router as assets_router
|
||||
from .routes.home_assistant import router as home_assistant_router
|
||||
from .routes.game_integration import router as game_integration_router
|
||||
|
||||
router = APIRouter()
|
||||
router.include_router(system_router)
|
||||
@@ -52,5 +53,6 @@ router.include_router(weather_sources_router)
|
||||
router.include_router(update_router)
|
||||
router.include_router(assets_router)
|
||||
router.include_router(home_assistant_router)
|
||||
router.include_router(game_integration_router)
|
||||
|
||||
__all__ = ["router"]
|
||||
|
||||
@@ -33,6 +33,8 @@ from wled_controller.core.processing.sync_clock_manager import SyncClockManager
|
||||
from wled_controller.core.update.update_service import UpdateService
|
||||
from wled_controller.storage.home_assistant_store import HomeAssistantStore
|
||||
from wled_controller.core.home_assistant.ha_manager import HomeAssistantManager
|
||||
from wled_controller.storage.game_integration_store import GameIntegrationStore
|
||||
from wled_controller.core.game_integration.event_bus import GameEventBus
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
@@ -143,6 +145,14 @@ def get_ha_manager() -> HomeAssistantManager:
|
||||
return _get("ha_manager", "Home Assistant manager")
|
||||
|
||||
|
||||
def get_game_integration_store() -> GameIntegrationStore:
|
||||
return _get("game_integration_store", "Game integration store")
|
||||
|
||||
|
||||
def get_game_event_bus() -> GameEventBus:
|
||||
return _get("game_event_bus", "Game event bus")
|
||||
|
||||
|
||||
def get_database() -> Database:
|
||||
return _get("database", "Database")
|
||||
|
||||
@@ -203,6 +213,8 @@ def init_dependencies(
|
||||
asset_store: AssetStore | None = None,
|
||||
ha_store: HomeAssistantStore | None = None,
|
||||
ha_manager: HomeAssistantManager | None = None,
|
||||
game_integration_store: GameIntegrationStore | None = None,
|
||||
game_event_bus: GameEventBus | None = None,
|
||||
):
|
||||
"""Initialize global dependencies."""
|
||||
_deps.update(
|
||||
@@ -232,5 +244,7 @@ def init_dependencies(
|
||||
"asset_store": asset_store,
|
||||
"ha_store": ha_store,
|
||||
"ha_manager": ha_manager,
|
||||
"game_integration_store": game_integration_store,
|
||||
"game_event_bus": game_event_bus,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -16,19 +16,19 @@ from wled_controller.api.schemas.automations import (
|
||||
AutomationListResponse,
|
||||
AutomationResponse,
|
||||
AutomationUpdate,
|
||||
ConditionSchema,
|
||||
RuleSchema,
|
||||
)
|
||||
from wled_controller.core.automations.automation_engine import AutomationEngine
|
||||
from wled_controller.storage.automation import (
|
||||
AlwaysCondition,
|
||||
ApplicationCondition,
|
||||
Condition,
|
||||
DisplayStateCondition,
|
||||
MQTTCondition,
|
||||
StartupCondition,
|
||||
SystemIdleCondition,
|
||||
TimeOfDayCondition,
|
||||
WebhookCondition,
|
||||
ApplicationRule,
|
||||
DisplayStateRule,
|
||||
HomeAssistantRule,
|
||||
MQTTRule,
|
||||
Rule,
|
||||
StartupRule,
|
||||
SystemIdleRule,
|
||||
TimeOfDayRule,
|
||||
WebhookRule,
|
||||
)
|
||||
from wled_controller.storage.automation_store import AutomationStore
|
||||
from wled_controller.storage.scene_preset_store import ScenePresetStore
|
||||
@@ -41,69 +41,78 @@ router = APIRouter()
|
||||
|
||||
# ===== Helpers =====
|
||||
|
||||
def _condition_from_schema(s: ConditionSchema) -> Condition:
|
||||
_SCHEMA_TO_CONDITION = {
|
||||
"always": lambda: AlwaysCondition(),
|
||||
"application": lambda: ApplicationCondition(
|
||||
|
||||
def _rule_from_schema(s: RuleSchema) -> Rule:
|
||||
_SCHEMA_TO_RULE = {
|
||||
"application": lambda: ApplicationRule(
|
||||
apps=s.apps or [],
|
||||
match_type=s.match_type or "running",
|
||||
),
|
||||
"time_of_day": lambda: TimeOfDayCondition(
|
||||
"time_of_day": lambda: TimeOfDayRule(
|
||||
start_time=s.start_time or "00:00",
|
||||
end_time=s.end_time or "23:59",
|
||||
),
|
||||
"system_idle": lambda: SystemIdleCondition(
|
||||
"system_idle": lambda: SystemIdleRule(
|
||||
idle_minutes=s.idle_minutes if s.idle_minutes is not None else 5,
|
||||
when_idle=s.when_idle if s.when_idle is not None else True,
|
||||
),
|
||||
"display_state": lambda: DisplayStateCondition(
|
||||
"display_state": lambda: DisplayStateRule(
|
||||
state=s.state or "on",
|
||||
),
|
||||
"mqtt": lambda: MQTTCondition(
|
||||
"mqtt": lambda: MQTTRule(
|
||||
topic=s.topic or "",
|
||||
payload=s.payload or "",
|
||||
match_mode=s.match_mode or "exact",
|
||||
),
|
||||
"webhook": lambda: WebhookCondition(
|
||||
"webhook": lambda: WebhookRule(
|
||||
token=s.token or secrets.token_hex(16),
|
||||
),
|
||||
"startup": lambda: StartupCondition(),
|
||||
"startup": lambda: StartupRule(),
|
||||
"home_assistant": lambda: HomeAssistantRule(
|
||||
ha_source_id=s.ha_source_id or "",
|
||||
entity_id=s.entity_id or "",
|
||||
state=s.state or "",
|
||||
match_mode=s.match_mode or "exact",
|
||||
),
|
||||
}
|
||||
factory = _SCHEMA_TO_CONDITION.get(s.condition_type)
|
||||
factory = _SCHEMA_TO_RULE.get(s.rule_type)
|
||||
if factory is None:
|
||||
raise ValueError(f"Unknown condition type: {s.condition_type}")
|
||||
raise ValueError(f"Unknown rule type: {s.rule_type}")
|
||||
return factory()
|
||||
|
||||
|
||||
def _condition_to_schema(c: Condition) -> ConditionSchema:
|
||||
d = c.to_dict()
|
||||
return ConditionSchema(**d)
|
||||
def _rule_to_schema(r: Rule) -> RuleSchema:
|
||||
d = r.to_dict()
|
||||
return RuleSchema(**d)
|
||||
|
||||
|
||||
def _automation_to_response(automation, engine: AutomationEngine, request: Request = None) -> AutomationResponse:
|
||||
def _automation_to_response(
|
||||
automation, engine: AutomationEngine, request: Request = None
|
||||
) -> AutomationResponse:
|
||||
state = engine.get_automation_state(automation.id)
|
||||
|
||||
# Build webhook URL from the first webhook condition (if any)
|
||||
# Build webhook URL from the first webhook rule (if any)
|
||||
webhook_url = None
|
||||
for c in automation.conditions:
|
||||
if isinstance(c, WebhookCondition) and c.token:
|
||||
for r in automation.rules:
|
||||
if isinstance(r, WebhookRule) and r.token:
|
||||
# Prefer configured external URL, fall back to request base URL
|
||||
from wled_controller.api.routes.system import load_external_url
|
||||
|
||||
ext = load_external_url()
|
||||
if ext:
|
||||
webhook_url = ext + f"/api/v1/webhooks/{c.token}"
|
||||
webhook_url = ext + f"/api/v1/webhooks/{r.token}"
|
||||
elif request:
|
||||
webhook_url = str(request.base_url).rstrip("/") + f"/api/v1/webhooks/{c.token}"
|
||||
webhook_url = str(request.base_url).rstrip("/") + f"/api/v1/webhooks/{r.token}"
|
||||
else:
|
||||
webhook_url = f"/api/v1/webhooks/{c.token}"
|
||||
webhook_url = f"/api/v1/webhooks/{r.token}"
|
||||
break
|
||||
|
||||
return AutomationResponse(
|
||||
id=automation.id,
|
||||
name=automation.name,
|
||||
enabled=automation.enabled,
|
||||
condition_logic=automation.condition_logic,
|
||||
conditions=[_condition_to_schema(c) for c in automation.conditions],
|
||||
rule_logic=automation.rule_logic,
|
||||
rules=[_rule_to_schema(r) for r in automation.rules],
|
||||
scene_preset_id=automation.scene_preset_id,
|
||||
deactivation_mode=automation.deactivation_mode,
|
||||
deactivation_scene_preset_id=automation.deactivation_scene_preset_id,
|
||||
@@ -117,9 +126,11 @@ def _automation_to_response(automation, engine: AutomationEngine, request: Reque
|
||||
)
|
||||
|
||||
|
||||
def _validate_condition_logic(logic: str) -> None:
|
||||
def _validate_rule_logic(logic: str) -> None:
|
||||
if logic not in ("or", "and"):
|
||||
raise HTTPException(status_code=400, detail=f"Invalid condition_logic: {logic}. Must be 'or' or 'and'.")
|
||||
raise HTTPException(
|
||||
status_code=400, detail=f"Invalid rule_logic: {logic}. Must be 'or' or 'and'."
|
||||
)
|
||||
|
||||
|
||||
def _validate_scene_refs(
|
||||
@@ -136,11 +147,14 @@ def _validate_scene_refs(
|
||||
try:
|
||||
scene_store.get_preset(sid)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail=f"Scene preset not found: {sid} ({label})")
|
||||
raise HTTPException(
|
||||
status_code=400, detail=f"Scene preset not found: {sid} ({label})"
|
||||
)
|
||||
|
||||
|
||||
# ===== CRUD Endpoints =====
|
||||
|
||||
|
||||
@router.post(
|
||||
"/api/v1/automations",
|
||||
response_model=AutomationResponse,
|
||||
@@ -156,11 +170,11 @@ async def create_automation(
|
||||
scene_store: ScenePresetStore = Depends(get_scene_preset_store),
|
||||
):
|
||||
"""Create a new automation."""
|
||||
_validate_condition_logic(data.condition_logic)
|
||||
_validate_rule_logic(data.rule_logic)
|
||||
_validate_scene_refs(data.scene_preset_id, data.deactivation_scene_preset_id, scene_store)
|
||||
|
||||
try:
|
||||
conditions = [_condition_from_schema(c) for c in data.conditions]
|
||||
rules = [_rule_from_schema(r) for r in data.rules]
|
||||
except EntityNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
@@ -170,8 +184,8 @@ async def create_automation(
|
||||
automation = store.create_automation(
|
||||
name=data.name,
|
||||
enabled=data.enabled,
|
||||
condition_logic=data.condition_logic,
|
||||
conditions=conditions,
|
||||
rule_logic=data.rule_logic,
|
||||
rules=rules,
|
||||
scene_preset_id=data.scene_preset_id,
|
||||
deactivation_mode=data.deactivation_mode,
|
||||
deactivation_scene_preset_id=data.deactivation_scene_preset_id,
|
||||
@@ -240,16 +254,16 @@ async def update_automation(
|
||||
scene_store: ScenePresetStore = Depends(get_scene_preset_store),
|
||||
):
|
||||
"""Update an automation."""
|
||||
if data.condition_logic is not None:
|
||||
_validate_condition_logic(data.condition_logic)
|
||||
if data.rule_logic is not None:
|
||||
_validate_rule_logic(data.rule_logic)
|
||||
|
||||
# Validate scene refs (only the ones being updated)
|
||||
_validate_scene_refs(data.scene_preset_id, data.deactivation_scene_preset_id, scene_store)
|
||||
|
||||
conditions = None
|
||||
if data.conditions is not None:
|
||||
rules = None
|
||||
if data.rules is not None:
|
||||
try:
|
||||
conditions = [_condition_from_schema(c) for c in data.conditions]
|
||||
rules = [_rule_from_schema(r) for r in data.rules]
|
||||
except EntityNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
@@ -266,8 +280,8 @@ async def update_automation(
|
||||
automation_id=automation_id,
|
||||
name=data.name,
|
||||
enabled=data.enabled,
|
||||
condition_logic=data.condition_logic,
|
||||
conditions=conditions,
|
||||
rule_logic=data.rule_logic,
|
||||
rules=rules,
|
||||
deactivation_mode=data.deactivation_mode,
|
||||
tags=data.tags,
|
||||
)
|
||||
@@ -280,7 +294,7 @@ async def update_automation(
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
# Re-evaluate immediately if automation is enabled (may have new conditions/scene)
|
||||
# Re-evaluate immediately if automation is enabled (may have new rules/scene)
|
||||
if automation.enabled:
|
||||
await engine.trigger_evaluate()
|
||||
|
||||
@@ -313,6 +327,7 @@ async def delete_automation(
|
||||
|
||||
# ===== Enable/Disable =====
|
||||
|
||||
|
||||
@router.post(
|
||||
"/api/v1/automations/{automation_id}/enable",
|
||||
response_model=AutomationResponse,
|
||||
|
||||
@@ -0,0 +1,661 @@
|
||||
"""Game integration API routes.
|
||||
|
||||
CRUD for game integration configs, event ingestion endpoint,
|
||||
adapter metadata, and diagnostics.
|
||||
"""
|
||||
|
||||
import threading
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
|
||||
from wled_controller.api.auth import AuthRequired
|
||||
from wled_controller.api.dependencies import (
|
||||
fire_entity_event,
|
||||
get_database,
|
||||
get_game_integration_store,
|
||||
get_game_event_bus,
|
||||
)
|
||||
from wled_controller.api.schemas.game_integration import (
|
||||
AdapterInfoResponse,
|
||||
AdapterListResponse,
|
||||
ApplyPresetRequest,
|
||||
AutoSetupResponse,
|
||||
EffectPresetResponse,
|
||||
EventMappingSchema,
|
||||
GameEventPayload,
|
||||
GameEventResponse,
|
||||
GameIntegrationCreate,
|
||||
GameIntegrationListResponse,
|
||||
GameIntegrationResponse,
|
||||
GameIntegrationStatusResponse,
|
||||
GameIntegrationUpdate,
|
||||
PresetListResponse,
|
||||
RecentEventsResponse,
|
||||
)
|
||||
from wled_controller.core.game_integration.adapter_registry import AdapterRegistry
|
||||
from wled_controller.core.game_integration.event_bus import GameEventBus
|
||||
from wled_controller.core.game_integration.events import GameEvent
|
||||
from wled_controller.storage.base_store import EntityNotFoundError
|
||||
from wled_controller.storage.game_integration import EventMapping
|
||||
from wled_controller.storage.game_integration_store import GameIntegrationStore
|
||||
from wled_controller.utils import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# ── Per-integration runtime state (in-memory, not persisted) ──────────────
|
||||
|
||||
_integration_state_lock = threading.Lock()
|
||||
|
||||
# integration_id -> prev_state dict for diff-based trigger detection
|
||||
_prev_states: dict[str, dict[str, Any]] = {}
|
||||
|
||||
# integration_id -> runtime stats
|
||||
_integration_stats: dict[str, dict[str, Any]] = {}
|
||||
|
||||
|
||||
def _schema_to_fields(schema: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""Convert a JSON Schema object into a flat list of field descriptors.
|
||||
|
||||
The frontend expects [{name, type, label, default, required, hint}, ...].
|
||||
"""
|
||||
properties = schema.get("properties", {})
|
||||
required_set = set(schema.get("required", []))
|
||||
fields: list[dict[str, Any]] = []
|
||||
for name, prop in properties.items():
|
||||
field: dict[str, Any] = {
|
||||
"name": name,
|
||||
"type": prop.get("type", "string"),
|
||||
"label": prop.get("title", name),
|
||||
}
|
||||
if "default" in prop:
|
||||
field["default"] = prop["default"]
|
||||
if name in required_set:
|
||||
field["required"] = True
|
||||
desc = prop.get("description")
|
||||
if desc:
|
||||
field["hint"] = desc
|
||||
fields.append(field)
|
||||
return fields
|
||||
|
||||
|
||||
def _get_prev_state(integration_id: str) -> dict[str, Any]:
|
||||
"""Get or create the prev_state dict for an integration."""
|
||||
with _integration_state_lock:
|
||||
if integration_id not in _prev_states:
|
||||
_prev_states[integration_id] = {}
|
||||
return _prev_states[integration_id]
|
||||
|
||||
|
||||
def _set_prev_state(integration_id: str, state: dict[str, Any]) -> None:
|
||||
"""Update the prev_state dict for an integration."""
|
||||
with _integration_state_lock:
|
||||
_prev_states[integration_id] = state
|
||||
|
||||
|
||||
def _record_events(integration_id: str, events: list[GameEvent]) -> None:
|
||||
"""Record event stats for an integration."""
|
||||
with _integration_state_lock:
|
||||
if integration_id not in _integration_stats:
|
||||
_integration_stats[integration_id] = {
|
||||
"event_count": 0,
|
||||
"event_counts_by_type": {},
|
||||
"last_event_time": None,
|
||||
}
|
||||
stats = _integration_stats[integration_id]
|
||||
for event in events:
|
||||
stats["event_count"] += 1
|
||||
stats["event_counts_by_type"][event.event_type] = (
|
||||
stats["event_counts_by_type"].get(event.event_type, 0) + 1
|
||||
)
|
||||
stats["last_event_time"] = event.timestamp
|
||||
|
||||
|
||||
def _get_stats(integration_id: str) -> dict[str, Any]:
|
||||
"""Get runtime stats for an integration."""
|
||||
with _integration_state_lock:
|
||||
return _integration_stats.get(
|
||||
integration_id,
|
||||
{"event_count": 0, "event_counts_by_type": {}, "last_event_time": None},
|
||||
)
|
||||
|
||||
|
||||
def _cleanup_state(integration_id: str) -> None:
|
||||
"""Remove runtime state for a deleted integration."""
|
||||
with _integration_state_lock:
|
||||
_prev_states.pop(integration_id, None)
|
||||
_integration_stats.pop(integration_id, None)
|
||||
|
||||
|
||||
# ── Helper: convert config to response ────────────────────────────────────
|
||||
|
||||
|
||||
def _config_to_response(config: Any) -> GameIntegrationResponse:
|
||||
"""Convert a GameIntegrationConfig to its API response."""
|
||||
from wled_controller.api.schemas.game_integration import EventMappingSchema
|
||||
|
||||
return GameIntegrationResponse(
|
||||
id=config.id,
|
||||
name=config.name,
|
||||
adapter_type=config.adapter_type,
|
||||
enabled=config.enabled,
|
||||
adapter_config=config.adapter_config,
|
||||
event_mappings=[
|
||||
EventMappingSchema(
|
||||
event_type=m.event_type,
|
||||
effect=m.effect,
|
||||
color=m.color,
|
||||
duration_ms=m.duration_ms,
|
||||
intensity=m.intensity,
|
||||
priority=m.priority,
|
||||
)
|
||||
for m in config.event_mappings
|
||||
],
|
||||
created_at=config.created_at,
|
||||
updated_at=config.updated_at,
|
||||
description=config.description,
|
||||
tags=config.tags,
|
||||
)
|
||||
|
||||
|
||||
# ── Effect Presets (must be before /{integration_id} routes) ────────────
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/v1/game-integrations/presets",
|
||||
response_model=PresetListResponse,
|
||||
tags=["Game Integration"],
|
||||
)
|
||||
async def list_presets(_auth: AuthRequired):
|
||||
"""List all available built-in effect presets."""
|
||||
from wled_controller.core.game_integration.presets import get_all_presets
|
||||
|
||||
presets = get_all_presets()
|
||||
responses = [
|
||||
EffectPresetResponse(
|
||||
key=p.key,
|
||||
name=p.name,
|
||||
description=p.description,
|
||||
target_game_types=list(p.target_game_types),
|
||||
event_mappings=[
|
||||
EventMappingSchema(
|
||||
event_type=m.event_type,
|
||||
effect=m.effect,
|
||||
color=list(m.color),
|
||||
duration_ms=m.duration_ms,
|
||||
intensity=m.intensity,
|
||||
priority=m.priority,
|
||||
)
|
||||
for m in p.event_mappings
|
||||
],
|
||||
)
|
||||
for p in presets
|
||||
]
|
||||
return PresetListResponse(presets=responses, count=len(responses))
|
||||
|
||||
|
||||
# ── CRUD Endpoints ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/v1/game-integrations",
|
||||
response_model=GameIntegrationListResponse,
|
||||
tags=["Game Integration"],
|
||||
)
|
||||
async def list_integrations(
|
||||
_auth: AuthRequired,
|
||||
store: GameIntegrationStore = Depends(get_game_integration_store),
|
||||
):
|
||||
"""List all game integration configs."""
|
||||
try:
|
||||
configs = store.get_all_integrations()
|
||||
responses = [_config_to_response(c) for c in configs]
|
||||
return GameIntegrationListResponse(
|
||||
integrations=responses,
|
||||
count=len(responses),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Failed to list game integrations: %s", e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.post(
|
||||
"/api/v1/game-integrations",
|
||||
response_model=GameIntegrationResponse,
|
||||
tags=["Game Integration"],
|
||||
status_code=201,
|
||||
)
|
||||
async def create_integration(
|
||||
data: GameIntegrationCreate,
|
||||
_auth: AuthRequired,
|
||||
store: GameIntegrationStore = Depends(get_game_integration_store),
|
||||
):
|
||||
"""Create a new game integration config."""
|
||||
try:
|
||||
mappings = [
|
||||
EventMapping(
|
||||
event_type=m.event_type,
|
||||
effect=m.effect,
|
||||
color=list(m.color),
|
||||
duration_ms=m.duration_ms,
|
||||
intensity=m.intensity,
|
||||
priority=m.priority,
|
||||
)
|
||||
for m in data.event_mappings
|
||||
]
|
||||
|
||||
config = store.create_integration(
|
||||
name=data.name,
|
||||
adapter_type=data.adapter_type,
|
||||
enabled=data.enabled,
|
||||
adapter_config=data.adapter_config,
|
||||
event_mappings=mappings,
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
)
|
||||
|
||||
fire_entity_event("game_integration", "created", config.id)
|
||||
return _config_to_response(config)
|
||||
|
||||
except EntityNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error("Failed to create game integration: %s", e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/v1/game-integrations/{integration_id}",
|
||||
response_model=GameIntegrationResponse,
|
||||
tags=["Game Integration"],
|
||||
)
|
||||
async def get_integration(
|
||||
integration_id: str,
|
||||
_auth: AuthRequired,
|
||||
store: GameIntegrationStore = Depends(get_game_integration_store),
|
||||
):
|
||||
"""Get a game integration config by ID."""
|
||||
try:
|
||||
config = store.get_integration(integration_id)
|
||||
return _config_to_response(config)
|
||||
except EntityNotFoundError:
|
||||
raise HTTPException(status_code=404, detail=f"Game integration {integration_id} not found")
|
||||
|
||||
|
||||
@router.put(
|
||||
"/api/v1/game-integrations/{integration_id}",
|
||||
response_model=GameIntegrationResponse,
|
||||
tags=["Game Integration"],
|
||||
)
|
||||
async def update_integration(
|
||||
integration_id: str,
|
||||
data: GameIntegrationUpdate,
|
||||
_auth: AuthRequired,
|
||||
store: GameIntegrationStore = Depends(get_game_integration_store),
|
||||
):
|
||||
"""Update a game integration config."""
|
||||
try:
|
||||
mappings = None
|
||||
if data.event_mappings is not None:
|
||||
mappings = [
|
||||
EventMapping(
|
||||
event_type=m.event_type,
|
||||
effect=m.effect,
|
||||
color=list(m.color),
|
||||
duration_ms=m.duration_ms,
|
||||
intensity=m.intensity,
|
||||
priority=m.priority,
|
||||
)
|
||||
for m in data.event_mappings
|
||||
]
|
||||
|
||||
config = store.update_integration(
|
||||
integration_id=integration_id,
|
||||
name=data.name,
|
||||
adapter_type=data.adapter_type,
|
||||
enabled=data.enabled,
|
||||
adapter_config=data.adapter_config,
|
||||
event_mappings=mappings,
|
||||
description=data.description,
|
||||
tags=data.tags,
|
||||
)
|
||||
|
||||
fire_entity_event("game_integration", "updated", integration_id)
|
||||
return _config_to_response(config)
|
||||
|
||||
except EntityNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error("Failed to update game integration: %s", e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/api/v1/game-integrations/{integration_id}",
|
||||
status_code=204,
|
||||
tags=["Game Integration"],
|
||||
)
|
||||
async def delete_integration(
|
||||
integration_id: str,
|
||||
_auth: AuthRequired,
|
||||
store: GameIntegrationStore = Depends(get_game_integration_store),
|
||||
):
|
||||
"""Delete a game integration config."""
|
||||
try:
|
||||
store.delete_integration(integration_id)
|
||||
_cleanup_state(integration_id)
|
||||
fire_entity_event("game_integration", "deleted", integration_id)
|
||||
except EntityNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error("Failed to delete game integration: %s", e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
# ── Event Ingestion ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.post(
|
||||
"/api/v1/game-integrations/{integration_id}/event",
|
||||
tags=["Game Integration"],
|
||||
status_code=204,
|
||||
)
|
||||
async def ingest_event(
|
||||
integration_id: str,
|
||||
payload: GameEventPayload,
|
||||
request: Request,
|
||||
store: GameIntegrationStore = Depends(get_game_integration_store),
|
||||
event_bus: GameEventBus = Depends(get_game_event_bus),
|
||||
):
|
||||
"""Receive a game event payload from a game client.
|
||||
|
||||
This endpoint is designed for low-latency ingestion (games send at
|
||||
16-64 Hz). Auth is adapter-level: the adapter's validate_auth() is
|
||||
called before standard API auth.
|
||||
|
||||
No AuthRequired dependency — adapter-level auth is used instead.
|
||||
"""
|
||||
try:
|
||||
config = store.get_integration(integration_id)
|
||||
except EntityNotFoundError:
|
||||
raise HTTPException(status_code=404, detail=f"Game integration {integration_id} not found")
|
||||
|
||||
if not config.enabled:
|
||||
raise HTTPException(status_code=409, detail="Integration is disabled")
|
||||
|
||||
# Look up adapter
|
||||
try:
|
||||
adapter_cls = AdapterRegistry.get_adapter(config.adapter_type)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
# Adapter-level auth check
|
||||
headers = dict(request.headers)
|
||||
if not adapter_cls.validate_auth(headers, payload.data, config.adapter_config):
|
||||
raise HTTPException(status_code=403, detail="Adapter authentication failed")
|
||||
|
||||
# Parse payload through adapter
|
||||
prev_state = _get_prev_state(integration_id)
|
||||
try:
|
||||
events, new_state = adapter_cls.parse_payload(
|
||||
payload.data, config.adapter_config, prev_state
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Adapter %s failed to parse payload for %s: %s",
|
||||
config.adapter_type,
|
||||
integration_id,
|
||||
e,
|
||||
)
|
||||
raise HTTPException(status_code=400, detail=f"Failed to parse payload: {e}")
|
||||
|
||||
_set_prev_state(integration_id, new_state)
|
||||
|
||||
# Publish events to the bus
|
||||
for event in events:
|
||||
event_bus.publish(event)
|
||||
|
||||
# Track stats
|
||||
if events:
|
||||
_record_events(integration_id, events)
|
||||
|
||||
|
||||
# ── Status / Diagnostics ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/v1/game-integrations/{integration_id}/status",
|
||||
response_model=GameIntegrationStatusResponse,
|
||||
tags=["Game Integration"],
|
||||
)
|
||||
async def get_integration_status(
|
||||
integration_id: str,
|
||||
_auth: AuthRequired,
|
||||
store: GameIntegrationStore = Depends(get_game_integration_store),
|
||||
):
|
||||
"""Get runtime status for a game integration."""
|
||||
try:
|
||||
config = store.get_integration(integration_id)
|
||||
except EntityNotFoundError:
|
||||
raise HTTPException(status_code=404, detail=f"Game integration {integration_id} not found")
|
||||
|
||||
stats = _get_stats(integration_id)
|
||||
|
||||
# Consider "connected" if we received an event in the last 30 seconds
|
||||
last_event_time = stats["last_event_time"]
|
||||
connected = False
|
||||
if last_event_time is not None:
|
||||
connected = (time.monotonic() - last_event_time) < 30.0
|
||||
|
||||
return GameIntegrationStatusResponse(
|
||||
integration_id=integration_id,
|
||||
enabled=config.enabled,
|
||||
connected=connected,
|
||||
last_event_time=last_event_time,
|
||||
event_count=stats["event_count"],
|
||||
event_counts_by_type=stats["event_counts_by_type"],
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/v1/game-integrations/{integration_id}/events",
|
||||
response_model=RecentEventsResponse,
|
||||
tags=["Game Integration"],
|
||||
)
|
||||
async def get_recent_events(
|
||||
integration_id: str,
|
||||
_auth: AuthRequired,
|
||||
store: GameIntegrationStore = Depends(get_game_integration_store),
|
||||
event_bus: GameEventBus = Depends(get_game_event_bus),
|
||||
limit: int = 50,
|
||||
):
|
||||
"""Get recent events for a game integration (for debugging)."""
|
||||
try:
|
||||
store.get_integration(integration_id)
|
||||
except EntityNotFoundError:
|
||||
raise HTTPException(status_code=404, detail=f"Game integration {integration_id} not found")
|
||||
|
||||
# Filter bus recent events to this integration
|
||||
all_recent = event_bus.get_recent_events(limit=200)
|
||||
filtered = [e for e in all_recent if e.adapter_id == integration_id][-limit:]
|
||||
|
||||
event_responses = [
|
||||
GameEventResponse(
|
||||
adapter_id=e.adapter_id,
|
||||
event_type=e.event_type,
|
||||
value=e.value,
|
||||
timestamp=e.timestamp,
|
||||
raw_data=e.raw_data,
|
||||
)
|
||||
for e in filtered
|
||||
]
|
||||
|
||||
return RecentEventsResponse(
|
||||
integration_id=integration_id,
|
||||
events=event_responses,
|
||||
count=len(event_responses),
|
||||
)
|
||||
|
||||
|
||||
# ── Adapter Metadata ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/v1/game-adapters",
|
||||
response_model=AdapterListResponse,
|
||||
tags=["Game Integration"],
|
||||
)
|
||||
async def list_adapters(_auth: AuthRequired):
|
||||
"""List all available game adapter types with metadata."""
|
||||
try:
|
||||
all_adapters = AdapterRegistry.get_all_adapters()
|
||||
responses = []
|
||||
for adapter_type, adapter_cls in all_adapters.items():
|
||||
responses.append(
|
||||
AdapterInfoResponse(
|
||||
adapter_type=adapter_type,
|
||||
display_name=adapter_cls.DISPLAY_NAME,
|
||||
game_name=adapter_cls.GAME_NAME,
|
||||
supported_events=list(adapter_cls.SUPPORTED_EVENTS),
|
||||
config_schema=_schema_to_fields(adapter_cls.get_config_schema()),
|
||||
setup_instructions=adapter_cls.get_setup_instructions(),
|
||||
supports_auto_setup=adapter_cls.supports_auto_setup(),
|
||||
)
|
||||
)
|
||||
return AdapterListResponse(adapters=responses, count=len(responses))
|
||||
except Exception as e:
|
||||
logger.error("Failed to list adapters: %s", e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
# ── Apply Preset ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.post(
|
||||
"/api/v1/game-integrations/{integration_id}/apply-preset",
|
||||
response_model=GameIntegrationResponse,
|
||||
tags=["Game Integration"],
|
||||
)
|
||||
async def apply_preset(
|
||||
integration_id: str,
|
||||
data: ApplyPresetRequest,
|
||||
_auth: AuthRequired,
|
||||
store: GameIntegrationStore = Depends(get_game_integration_store),
|
||||
):
|
||||
"""Apply a built-in preset to a game integration.
|
||||
|
||||
If replace=true, replaces all existing mappings.
|
||||
If replace=false (default), appends preset mappings to existing ones.
|
||||
"""
|
||||
from wled_controller.core.game_integration.presets import get_preset
|
||||
|
||||
try:
|
||||
config = store.get_integration(integration_id)
|
||||
except EntityNotFoundError:
|
||||
raise HTTPException(status_code=404, detail=f"Game integration {integration_id} not found")
|
||||
|
||||
preset = get_preset(data.preset_key)
|
||||
if preset is None:
|
||||
raise HTTPException(status_code=404, detail=f"Preset '{data.preset_key}' not found")
|
||||
|
||||
if data.replace:
|
||||
new_mappings = list(preset.event_mappings)
|
||||
else:
|
||||
new_mappings = list(config.event_mappings) + list(preset.event_mappings)
|
||||
|
||||
try:
|
||||
updated = store.update_integration(
|
||||
integration_id=integration_id,
|
||||
event_mappings=new_mappings,
|
||||
)
|
||||
fire_entity_event("game_integration", "updated", integration_id)
|
||||
return _config_to_response(updated)
|
||||
except Exception as e:
|
||||
logger.error("Failed to apply preset: %s", e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
# ── Auto Setup ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.post(
|
||||
"/api/v1/game-integrations/{integration_id}/auto-setup",
|
||||
response_model=AutoSetupResponse,
|
||||
tags=["Game Integration"],
|
||||
)
|
||||
async def auto_setup_integration(
|
||||
integration_id: str,
|
||||
request: Request,
|
||||
_auth: AuthRequired,
|
||||
store: GameIntegrationStore = Depends(get_game_integration_store),
|
||||
):
|
||||
"""Automatically write game config files for an integration.
|
||||
|
||||
Detects the game installation and writes the GSI config file.
|
||||
Generates an auth token if not already set.
|
||||
"""
|
||||
try:
|
||||
config = store.get_integration(integration_id)
|
||||
except EntityNotFoundError:
|
||||
raise HTTPException(status_code=404, detail=f"Game integration {integration_id} not found")
|
||||
|
||||
# Look up adapter
|
||||
try:
|
||||
adapter_cls = AdapterRegistry.get_adapter(config.adapter_type)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
if not adapter_cls.supports_auto_setup():
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Adapter '{config.adapter_type}' does not support auto setup",
|
||||
)
|
||||
|
||||
# Determine server URL
|
||||
from wled_controller.api.routes.system_settings import load_external_url
|
||||
|
||||
db = get_database()
|
||||
server_url = load_external_url(db)
|
||||
if not server_url:
|
||||
host = request.headers.get("host", "localhost:8080")
|
||||
server_url = f"http://{host}"
|
||||
|
||||
# Run auto-setup
|
||||
try:
|
||||
result = adapter_cls.auto_setup(
|
||||
integration_id=integration_id,
|
||||
adapter_config=config.adapter_config,
|
||||
server_url=server_url,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Auto setup failed for %s: %s", integration_id, e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Auto setup failed: {e}")
|
||||
|
||||
# If a new token was generated, persist the updated adapter_config
|
||||
if result.get("token_generated") and result.get("adapter_config"):
|
||||
try:
|
||||
store.update_integration(
|
||||
integration_id=integration_id,
|
||||
adapter_config=result["adapter_config"],
|
||||
)
|
||||
fire_entity_event("game_integration", "updated", integration_id)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to save auto-generated token for %s: %s",
|
||||
integration_id,
|
||||
e,
|
||||
)
|
||||
|
||||
return AutoSetupResponse(
|
||||
success=result.get("success", False),
|
||||
file_path=result.get("file_path", ""),
|
||||
message=result.get("message", ""),
|
||||
token_generated=result.get("token_generated", False),
|
||||
)
|
||||
@@ -6,58 +6,58 @@ from typing import List, Optional
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class ConditionSchema(BaseModel):
|
||||
"""A single condition within an automation."""
|
||||
class RuleSchema(BaseModel):
|
||||
"""A single rule within an automation."""
|
||||
|
||||
condition_type: str = Field(description="Condition type discriminator (e.g. 'application')")
|
||||
# Application condition fields
|
||||
apps: Optional[List[str]] = Field(None, description="Process names (for application condition)")
|
||||
rule_type: str = Field(description="Rule type discriminator (e.g. 'application')")
|
||||
# Application rule fields
|
||||
apps: Optional[List[str]] = Field(None, description="Process names (for application rule)")
|
||||
match_type: Optional[str] = Field(
|
||||
None, description="'running' or 'topmost' (for application condition)"
|
||||
None, description="'running' or 'topmost' (for application rule)"
|
||||
)
|
||||
# Time-of-day condition fields
|
||||
start_time: Optional[str] = Field(
|
||||
None, description="Start time HH:MM (for time_of_day condition)"
|
||||
)
|
||||
end_time: Optional[str] = Field(None, description="End time HH:MM (for time_of_day condition)")
|
||||
# System idle condition fields
|
||||
# Time-of-day rule fields
|
||||
start_time: Optional[str] = Field(None, description="Start time HH:MM (for time_of_day rule)")
|
||||
end_time: Optional[str] = Field(None, description="End time HH:MM (for time_of_day rule)")
|
||||
# System idle rule fields
|
||||
idle_minutes: Optional[int] = Field(
|
||||
None, description="Idle timeout in minutes (for system_idle condition)"
|
||||
None, description="Idle timeout in minutes (for system_idle rule)"
|
||||
)
|
||||
when_idle: Optional[bool] = Field(
|
||||
None, description="True=active when idle (for system_idle condition)"
|
||||
None, description="True=active when idle (for system_idle rule)"
|
||||
)
|
||||
# Display state condition fields
|
||||
state: Optional[str] = Field(None, description="'on' or 'off' (for display_state condition)")
|
||||
# MQTT condition fields
|
||||
topic: Optional[str] = Field(None, description="MQTT topic to watch (for mqtt condition)")
|
||||
payload: Optional[str] = Field(None, description="Expected payload value (for mqtt condition)")
|
||||
# Display state rule fields
|
||||
state: Optional[str] = Field(None, description="'on' or 'off' (for display_state rule)")
|
||||
# MQTT rule fields
|
||||
topic: Optional[str] = Field(None, description="MQTT topic to watch (for mqtt rule)")
|
||||
payload: Optional[str] = Field(None, description="Expected payload value (for mqtt rule)")
|
||||
match_mode: Optional[str] = Field(
|
||||
None, description="'exact', 'contains', or 'regex' (for mqtt condition)"
|
||||
None, description="'exact', 'contains', or 'regex' (for mqtt rule)"
|
||||
)
|
||||
# Webhook condition fields
|
||||
# Webhook rule fields
|
||||
token: Optional[str] = Field(
|
||||
None, description="Secret token for webhook URL (for webhook condition)"
|
||||
None, description="Secret token for webhook URL (for webhook rule)"
|
||||
)
|
||||
# Home Assistant condition fields
|
||||
# Home Assistant rule fields
|
||||
ha_source_id: Optional[str] = Field(
|
||||
None, description="Home Assistant source ID (for home_assistant condition)"
|
||||
None, description="Home Assistant source ID (for home_assistant rule)"
|
||||
)
|
||||
entity_id: Optional[str] = Field(
|
||||
None,
|
||||
description="HA entity ID, e.g. 'binary_sensor.front_door' (for home_assistant condition)",
|
||||
description="HA entity ID, e.g. 'binary_sensor.front_door' (for home_assistant rule)",
|
||||
)
|
||||
|
||||
|
||||
# Backward-compatible alias
|
||||
ConditionSchema = RuleSchema
|
||||
|
||||
|
||||
class AutomationCreate(BaseModel):
|
||||
"""Request to create an automation."""
|
||||
|
||||
name: str = Field(description="Automation name", min_length=1, max_length=100)
|
||||
enabled: bool = Field(default=True, description="Whether the automation is enabled")
|
||||
condition_logic: str = Field(default="or", description="How conditions combine: 'or' or 'and'")
|
||||
conditions: List[ConditionSchema] = Field(
|
||||
default_factory=list, description="List of conditions"
|
||||
)
|
||||
rule_logic: str = Field(default="or", description="How rules combine: 'or' or 'and'")
|
||||
rules: List[RuleSchema] = Field(default_factory=list, description="List of rules")
|
||||
scene_preset_id: Optional[str] = Field(None, description="Scene preset to activate")
|
||||
deactivation_mode: str = Field(
|
||||
default="none", description="'none', 'revert', or 'fallback_scene'"
|
||||
@@ -73,10 +73,8 @@ class AutomationUpdate(BaseModel):
|
||||
|
||||
name: Optional[str] = Field(None, description="Automation name", min_length=1, max_length=100)
|
||||
enabled: Optional[bool] = Field(None, description="Whether the automation is enabled")
|
||||
condition_logic: Optional[str] = Field(
|
||||
None, description="How conditions combine: 'or' or 'and'"
|
||||
)
|
||||
conditions: Optional[List[ConditionSchema]] = Field(None, description="List of conditions")
|
||||
rule_logic: Optional[str] = Field(None, description="How rules combine: 'or' or 'and'")
|
||||
rules: Optional[List[RuleSchema]] = Field(None, description="List of rules")
|
||||
scene_preset_id: Optional[str] = Field(None, description="Scene preset to activate")
|
||||
deactivation_mode: Optional[str] = Field(
|
||||
None, description="'none', 'revert', or 'fallback_scene'"
|
||||
@@ -93,14 +91,14 @@ class AutomationResponse(BaseModel):
|
||||
id: str = Field(description="Automation ID")
|
||||
name: str = Field(description="Automation name")
|
||||
enabled: bool = Field(description="Whether the automation is enabled")
|
||||
condition_logic: str = Field(description="Condition combination logic")
|
||||
conditions: List[ConditionSchema] = Field(description="List of conditions")
|
||||
rule_logic: str = Field(description="Rule combination logic")
|
||||
rules: List[RuleSchema] = Field(description="List of rules")
|
||||
scene_preset_id: Optional[str] = Field(None, description="Scene preset to activate")
|
||||
deactivation_mode: str = Field(default="none", description="Deactivation behavior")
|
||||
deactivation_scene_preset_id: Optional[str] = Field(None, description="Fallback scene preset")
|
||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||
webhook_url: Optional[str] = Field(
|
||||
None, description="Webhook URL for the first webhook condition (if any)"
|
||||
None, description="Webhook URL for the first webhook rule (if any)"
|
||||
)
|
||||
is_active: bool = Field(default=False, description="Whether the automation is currently active")
|
||||
last_activated_at: Optional[datetime] = Field(
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
"""Pydantic schemas for game integration API endpoints."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
# ── Event Mapping ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class EventMappingSchema(BaseModel):
|
||||
"""Maps a standard game event type to a visual effect."""
|
||||
|
||||
event_type: str = Field(description="Standard event type (e.g. 'health', 'kill')")
|
||||
effect: str = Field(default="flash", description="Effect name (flash, pulse, gradient)")
|
||||
color: List[int] = Field(
|
||||
default=[255, 0, 0],
|
||||
description="RGB color [R, G, B] (0-255 each)",
|
||||
min_length=3,
|
||||
max_length=3,
|
||||
)
|
||||
duration_ms: int = Field(default=500, ge=0, le=60000, description="Effect duration in ms")
|
||||
intensity: float = Field(default=1.0, ge=0.0, le=1.0, description="Effect intensity 0.0-1.0")
|
||||
priority: int = Field(default=0, ge=0, le=100, description="Priority for effect stacking")
|
||||
|
||||
|
||||
# ── CRUD Schemas ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class GameIntegrationCreate(BaseModel):
|
||||
"""Request to create a game integration config."""
|
||||
|
||||
name: str = Field(description="Integration name", min_length=1, max_length=100)
|
||||
adapter_type: str = Field(description="Adapter type identifier", min_length=1)
|
||||
enabled: bool = Field(default=True, description="Whether integration is active")
|
||||
adapter_config: Dict[str, Any] = Field(
|
||||
default_factory=dict, description="Adapter-specific settings"
|
||||
)
|
||||
event_mappings: List[EventMappingSchema] = Field(
|
||||
default_factory=list, description="Event-to-effect mappings"
|
||||
)
|
||||
description: Optional[str] = Field(None, description="Integration description", max_length=500)
|
||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||
|
||||
|
||||
class GameIntegrationUpdate(BaseModel):
|
||||
"""Request to update a game integration config."""
|
||||
|
||||
name: Optional[str] = Field(None, description="Integration name", min_length=1, max_length=100)
|
||||
adapter_type: Optional[str] = Field(None, description="Adapter type identifier", min_length=1)
|
||||
enabled: Optional[bool] = Field(None, description="Whether integration is active")
|
||||
adapter_config: Optional[Dict[str, Any]] = Field(None, description="Adapter-specific settings")
|
||||
event_mappings: Optional[List[EventMappingSchema]] = Field(
|
||||
None, description="Event-to-effect mappings"
|
||||
)
|
||||
description: Optional[str] = Field(None, description="Integration description", max_length=500)
|
||||
tags: Optional[List[str]] = Field(None, description="User-defined tags")
|
||||
|
||||
|
||||
class GameIntegrationResponse(BaseModel):
|
||||
"""Game integration config response."""
|
||||
|
||||
id: str = Field(description="Integration ID")
|
||||
name: str = Field(description="Integration name")
|
||||
adapter_type: str = Field(description="Adapter type identifier")
|
||||
enabled: bool = Field(description="Whether integration is active")
|
||||
adapter_config: Dict[str, Any] = Field(description="Adapter-specific settings")
|
||||
event_mappings: List[EventMappingSchema] = Field(description="Event-to-effect mappings")
|
||||
created_at: datetime = Field(description="Creation timestamp")
|
||||
updated_at: datetime = Field(description="Last update timestamp")
|
||||
description: Optional[str] = Field(None, description="Integration description")
|
||||
tags: List[str] = Field(default_factory=list, description="User-defined tags")
|
||||
|
||||
|
||||
class GameIntegrationListResponse(BaseModel):
|
||||
"""List of game integration configs."""
|
||||
|
||||
integrations: List[GameIntegrationResponse] = Field(
|
||||
description="List of game integration configs"
|
||||
)
|
||||
count: int = Field(description="Number of integrations")
|
||||
|
||||
|
||||
# ── Event Ingestion ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class GameEventPayload(BaseModel):
|
||||
"""Incoming game event payload from a game client.
|
||||
|
||||
The shape depends on the adapter — this is a generic envelope.
|
||||
The adapter's parse_payload() extracts standardized events.
|
||||
"""
|
||||
|
||||
data: Dict[str, Any] = Field(description="Raw game event data")
|
||||
|
||||
|
||||
# ── Adapter Metadata ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class AdapterInfoResponse(BaseModel):
|
||||
"""Metadata for a registered game adapter."""
|
||||
|
||||
adapter_type: str = Field(description="Adapter type identifier")
|
||||
display_name: str = Field(description="Human-readable adapter name")
|
||||
game_name: str = Field(description="Game this adapter supports")
|
||||
supported_events: List[str] = Field(description="Standard event types supported")
|
||||
config_schema: List[Dict[str, Any]] = Field(description="Flat list of config fields")
|
||||
setup_instructions: str = Field(description="Markdown setup guide")
|
||||
supports_auto_setup: bool = Field(
|
||||
default=False, description="Whether this adapter supports automatic config setup"
|
||||
)
|
||||
|
||||
|
||||
class AdapterListResponse(BaseModel):
|
||||
"""List of available game adapters."""
|
||||
|
||||
adapters: List[AdapterInfoResponse] = Field(description="Available adapters")
|
||||
count: int = Field(description="Number of adapters")
|
||||
|
||||
|
||||
# ── Status / Diagnostics ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
class GameIntegrationStatusResponse(BaseModel):
|
||||
"""Runtime status for a game integration."""
|
||||
|
||||
integration_id: str = Field(description="Integration ID")
|
||||
enabled: bool = Field(description="Whether integration is active")
|
||||
connected: bool = Field(description="Whether adapter is currently receiving data")
|
||||
last_event_time: Optional[float] = Field(None, description="Monotonic timestamp of last event")
|
||||
event_count: int = Field(default=0, description="Total events received")
|
||||
event_counts_by_type: Dict[str, int] = Field(
|
||||
default_factory=dict, description="Event counts per event type"
|
||||
)
|
||||
|
||||
|
||||
class GameEventResponse(BaseModel):
|
||||
"""A single game event for diagnostics."""
|
||||
|
||||
adapter_id: str = Field(description="Adapter that produced this event")
|
||||
event_type: str = Field(description="Standard event type")
|
||||
value: float = Field(description="Normalized value 0.0-1.0")
|
||||
timestamp: float = Field(description="Monotonic timestamp")
|
||||
raw_data: Dict[str, Any] = Field(default_factory=dict, description="Original game data")
|
||||
|
||||
|
||||
class RecentEventsResponse(BaseModel):
|
||||
"""Recent events for a game integration."""
|
||||
|
||||
integration_id: str = Field(description="Integration ID")
|
||||
events: List[GameEventResponse] = Field(description="Recent events (newest last)")
|
||||
count: int = Field(description="Number of events returned")
|
||||
|
||||
|
||||
# ── Presets ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class EffectPresetResponse(BaseModel):
|
||||
"""A built-in effect preset."""
|
||||
|
||||
key: str = Field(description="Unique preset key")
|
||||
name: str = Field(description="Display name")
|
||||
description: str = Field(description="One-line description")
|
||||
target_game_types: List[str] = Field(description="Genre tags (fps, moba, racing, any)")
|
||||
event_mappings: List[EventMappingSchema] = Field(description="Pre-configured mappings")
|
||||
|
||||
|
||||
class PresetListResponse(BaseModel):
|
||||
"""List of available effect presets."""
|
||||
|
||||
presets: List[EffectPresetResponse] = Field(description="Available presets")
|
||||
count: int = Field(description="Number of presets")
|
||||
|
||||
|
||||
class ApplyPresetRequest(BaseModel):
|
||||
"""Request to apply a preset to an integration."""
|
||||
|
||||
preset_key: str = Field(description="Key of the preset to apply")
|
||||
replace: bool = Field(
|
||||
default=False,
|
||||
description="If true, replace existing mappings; if false, append",
|
||||
)
|
||||
|
||||
|
||||
class AutoSetupResponse(BaseModel):
|
||||
"""Result of an auto-setup operation."""
|
||||
|
||||
success: bool = Field(description="Whether the setup completed successfully")
|
||||
file_path: str = Field(default="", description="Path to the config file written")
|
||||
message: str = Field(description="Human-readable result message")
|
||||
token_generated: bool = Field(
|
||||
default=False, description="Whether a new auth token was auto-generated"
|
||||
)
|
||||
@@ -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))
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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}">●</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 chrome.exe">${escapeHtml(appsValue)}</textarea>
|
||||
<textarea class="rule-apps" rows="3" placeholder="firefox.exe 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">▶</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">▶</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
@@ -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;
|
||||
|
||||
@@ -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">×</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">✕</button>
|
||||
<button class="btn btn-icon btn-primary" onclick="saveGameIntegration()" title="Save" data-i18n-title="settings.button.save" data-i18n-aria-label="aria.save">✓</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">✕</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">
|
||||
|
||||
Reference in New Issue
Block a user