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:
@@ -1,4 +1,4 @@
|
||||
"""Tests for AutomationEngine — condition evaluation in isolation."""
|
||||
"""Tests for AutomationEngine — rule evaluation in isolation."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import MagicMock, patch
|
||||
@@ -7,14 +7,13 @@ import pytest
|
||||
|
||||
from wled_controller.core.automations.automation_engine import AutomationEngine
|
||||
from wled_controller.storage.automation import (
|
||||
AlwaysCondition,
|
||||
ApplicationCondition,
|
||||
ApplicationRule,
|
||||
Automation,
|
||||
DisplayStateCondition,
|
||||
StartupCondition,
|
||||
SystemIdleCondition,
|
||||
TimeOfDayCondition,
|
||||
WebhookCondition,
|
||||
DisplayStateRule,
|
||||
StartupRule,
|
||||
SystemIdleRule,
|
||||
TimeOfDayRule,
|
||||
WebhookRule,
|
||||
)
|
||||
from wled_controller.storage.automation_store import AutomationStore
|
||||
|
||||
@@ -44,9 +43,7 @@ def engine(mock_store, mock_manager) -> AutomationEngine:
|
||||
causes access violations in the test environment, so we replace it
|
||||
with a simple MagicMock.
|
||||
"""
|
||||
with patch(
|
||||
"wled_controller.core.automations.automation_engine.PlatformDetector"
|
||||
):
|
||||
with patch("wled_controller.core.automations.automation_engine.PlatformDetector"):
|
||||
eng = AutomationEngine(
|
||||
automation_store=mock_store,
|
||||
processor_manager=mock_manager,
|
||||
@@ -56,21 +53,21 @@ def engine(mock_store, mock_manager) -> AutomationEngine:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Condition evaluation (unit-level)
|
||||
# Rule evaluation (unit-level)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestConditionEvaluation:
|
||||
"""Test _evaluate_condition for each condition type individually."""
|
||||
class TestRuleEvaluation:
|
||||
"""Test _evaluate_rule for each rule type individually."""
|
||||
|
||||
def _make_automation(self, conditions):
|
||||
def _make_automation(self, rules):
|
||||
now = datetime.now(timezone.utc)
|
||||
return Automation(
|
||||
id="test_auto",
|
||||
name="Test",
|
||||
enabled=True,
|
||||
condition_logic="or",
|
||||
conditions=conditions,
|
||||
rule_logic="or",
|
||||
rules=rules,
|
||||
scene_preset_id=None,
|
||||
deactivation_mode="none",
|
||||
deactivation_scene_preset_id=None,
|
||||
@@ -78,8 +75,8 @@ class TestConditionEvaluation:
|
||||
updated_at=now,
|
||||
)
|
||||
|
||||
def _eval(self, engine, condition, **kwargs):
|
||||
"""Invoke the private _evaluate_condition method."""
|
||||
def _eval(self, engine, rule, **kwargs):
|
||||
"""Invoke the private _evaluate_rule method."""
|
||||
defaults = dict(
|
||||
running_procs=set(),
|
||||
topmost_proc=None,
|
||||
@@ -89,8 +86,8 @@ class TestConditionEvaluation:
|
||||
display_state=None,
|
||||
)
|
||||
defaults.update(kwargs)
|
||||
return engine._evaluate_condition(
|
||||
condition,
|
||||
return engine._evaluate_rule(
|
||||
rule,
|
||||
defaults["running_procs"],
|
||||
defaults["topmost_proc"],
|
||||
defaults["topmost_fullscreen"],
|
||||
@@ -99,102 +96,103 @@ class TestConditionEvaluation:
|
||||
defaults["display_state"],
|
||||
)
|
||||
|
||||
def test_always_true(self, engine):
|
||||
assert self._eval(engine, AlwaysCondition()) is True
|
||||
|
||||
def test_startup_true(self, engine):
|
||||
assert self._eval(engine, StartupCondition()) is True
|
||||
assert self._eval(engine, StartupRule()) is True
|
||||
|
||||
def test_application_running_match(self, engine):
|
||||
cond = ApplicationCondition(apps=["chrome.exe"], match_type="running")
|
||||
rule = ApplicationRule(apps=["chrome.exe"], match_type="running")
|
||||
result = self._eval(
|
||||
engine, cond,
|
||||
engine,
|
||||
rule,
|
||||
running_procs={"chrome.exe", "explorer.exe"},
|
||||
)
|
||||
assert result is True
|
||||
|
||||
def test_application_running_no_match(self, engine):
|
||||
cond = ApplicationCondition(apps=["chrome.exe"], match_type="running")
|
||||
rule = ApplicationRule(apps=["chrome.exe"], match_type="running")
|
||||
result = self._eval(
|
||||
engine, cond,
|
||||
engine,
|
||||
rule,
|
||||
running_procs={"explorer.exe"},
|
||||
)
|
||||
assert result is False
|
||||
|
||||
def test_application_topmost_match(self, engine):
|
||||
cond = ApplicationCondition(apps=["game.exe"], match_type="topmost")
|
||||
rule = ApplicationRule(apps=["game.exe"], match_type="topmost")
|
||||
result = self._eval(
|
||||
engine, cond,
|
||||
engine,
|
||||
rule,
|
||||
topmost_proc="game.exe",
|
||||
)
|
||||
assert result is True
|
||||
|
||||
def test_application_topmost_no_match(self, engine):
|
||||
cond = ApplicationCondition(apps=["game.exe"], match_type="topmost")
|
||||
rule = ApplicationRule(apps=["game.exe"], match_type="topmost")
|
||||
result = self._eval(
|
||||
engine, cond,
|
||||
engine,
|
||||
rule,
|
||||
topmost_proc="chrome.exe",
|
||||
)
|
||||
assert result is False
|
||||
|
||||
def test_time_of_day_within_range(self, engine):
|
||||
cond = TimeOfDayCondition(start_time="00:00", end_time="23:59")
|
||||
result = self._eval(engine, cond)
|
||||
rule = TimeOfDayRule(start_time="00:00", end_time="23:59")
|
||||
result = self._eval(engine, rule)
|
||||
assert result is True
|
||||
|
||||
def test_system_idle_when_idle(self, engine):
|
||||
cond = SystemIdleCondition(idle_minutes=5, when_idle=True)
|
||||
result = self._eval(engine, cond, idle_seconds=600.0) # 10 minutes idle
|
||||
rule = SystemIdleRule(idle_minutes=5, when_idle=True)
|
||||
result = self._eval(engine, rule, idle_seconds=600.0) # 10 minutes idle
|
||||
assert result is True
|
||||
|
||||
def test_system_idle_not_idle(self, engine):
|
||||
cond = SystemIdleCondition(idle_minutes=5, when_idle=True)
|
||||
result = self._eval(engine, cond, idle_seconds=60.0) # 1 minute idle
|
||||
rule = SystemIdleRule(idle_minutes=5, when_idle=True)
|
||||
result = self._eval(engine, rule, idle_seconds=60.0) # 1 minute idle
|
||||
assert result is False
|
||||
|
||||
def test_system_idle_when_not_idle(self, engine):
|
||||
"""when_idle=False means active when user is NOT idle."""
|
||||
cond = SystemIdleCondition(idle_minutes=5, when_idle=False)
|
||||
result = self._eval(engine, cond, idle_seconds=60.0) # 1 min idle (not yet 5)
|
||||
rule = SystemIdleRule(idle_minutes=5, when_idle=False)
|
||||
result = self._eval(engine, rule, idle_seconds=60.0) # 1 min idle (not yet 5)
|
||||
assert result is True
|
||||
|
||||
def test_display_state_match(self, engine):
|
||||
cond = DisplayStateCondition(state="on")
|
||||
result = self._eval(engine, cond, display_state="on")
|
||||
rule = DisplayStateRule(state="on")
|
||||
result = self._eval(engine, rule, display_state="on")
|
||||
assert result is True
|
||||
|
||||
def test_display_state_no_match(self, engine):
|
||||
cond = DisplayStateCondition(state="off")
|
||||
result = self._eval(engine, cond, display_state="on")
|
||||
rule = DisplayStateRule(state="off")
|
||||
result = self._eval(engine, rule, display_state="on")
|
||||
assert result is False
|
||||
|
||||
def test_webhook_active(self, engine):
|
||||
cond = WebhookCondition(token="tok123")
|
||||
rule = WebhookRule(token="tok123")
|
||||
engine._webhook_states["tok123"] = True
|
||||
result = self._eval(engine, cond)
|
||||
result = self._eval(engine, rule)
|
||||
assert result is True
|
||||
|
||||
def test_webhook_inactive(self, engine):
|
||||
cond = WebhookCondition(token="tok123")
|
||||
rule = WebhookRule(token="tok123")
|
||||
# Not in _webhook_states → False
|
||||
result = self._eval(engine, cond)
|
||||
result = self._eval(engine, rule)
|
||||
assert result is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Condition logic (AND / OR)
|
||||
# Rule logic (AND / OR)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestConditionLogic:
|
||||
def _make_automation(self, conditions, logic="or"):
|
||||
class TestRuleLogic:
|
||||
def _make_automation(self, rules, logic="or"):
|
||||
now = datetime.now(timezone.utc)
|
||||
return Automation(
|
||||
id="logic_auto",
|
||||
name="Logic",
|
||||
enabled=True,
|
||||
condition_logic=logic,
|
||||
conditions=conditions,
|
||||
rule_logic=logic,
|
||||
rules=rules,
|
||||
scene_preset_id=None,
|
||||
deactivation_mode="none",
|
||||
deactivation_scene_preset_id=None,
|
||||
@@ -205,12 +203,12 @@ class TestConditionLogic:
|
||||
def test_or_any_true(self, engine):
|
||||
auto = self._make_automation(
|
||||
[
|
||||
ApplicationCondition(apps=["missing.exe"], match_type="running"),
|
||||
AlwaysCondition(),
|
||||
ApplicationRule(apps=["missing.exe"], match_type="running"),
|
||||
StartupRule(),
|
||||
],
|
||||
logic="or",
|
||||
)
|
||||
result = engine._evaluate_conditions(
|
||||
result = engine._evaluate_rules(
|
||||
auto,
|
||||
running_procs=set(),
|
||||
topmost_proc=None,
|
||||
@@ -224,12 +222,12 @@ class TestConditionLogic:
|
||||
def test_and_all_must_be_true(self, engine):
|
||||
auto = self._make_automation(
|
||||
[
|
||||
AlwaysCondition(),
|
||||
ApplicationCondition(apps=["missing.exe"], match_type="running"),
|
||||
StartupRule(),
|
||||
ApplicationRule(apps=["missing.exe"], match_type="running"),
|
||||
],
|
||||
logic="and",
|
||||
)
|
||||
result = engine._evaluate_conditions(
|
||||
result = engine._evaluate_rules(
|
||||
auto,
|
||||
running_procs=set(),
|
||||
topmost_proc=None,
|
||||
|
||||
Reference in New Issue
Block a user