feat: game integration system

Receive real-time events from games (CS2, Dota 2, LoL, etc.) and drive
LED effects through the existing color strip and value source pipelines.

Core:
- GameEventBus (thread-safe pub/sub) with standardized 23-type event vocabulary
- GameAdapter ABC + AdapterRegistry + MappingAdapter (YAML-driven)
- Built-in adapters: CS2 GSI, Dota 2 GSI, LoL Live Client, Generic Webhook
- Community YAML adapters: Minecraft, Valorant, Rocket League
- GameEventColorStripStream with 5 effects (flash/pulse/sweep/color_shift/breathing)
- GameEventValueSource with EMA smoothing and timeout
- 4 built-in effect presets (FPS Combat, MOBA Health, Racing, Generic Alert)
- Auto-setup for Valve GSI games (Steam path detection, cfg file writing)
- Demo capture engine exposed to non-demo mode

Frontend:
- Game tab in Streams tree navigation with integration cards
- Game integration editor modal with adapter picker, config fields, event mappings
- game_event source type in CSS and ValueSource editors
- Setup instructions overlay (markdown rendered)
- Live event monitor and connection test

API:
- Full CRUD for game integrations
- Event ingestion endpoint (adapter-level auth)
- Adapter metadata, presets, auto-setup, status/diagnostics endpoints
This commit is contained in:
2026-03-31 13:17:52 +03:00
parent b6713be390
commit 492bdb95e3
87 changed files with 12170 additions and 912 deletions
+106 -77
View File
@@ -1,18 +1,17 @@
"""Tests for AutomationStore — CRUD, conditions, name uniqueness."""
"""Tests for AutomationStore — CRUD, rules, name uniqueness."""
import pytest
from wled_controller.storage.automation import (
AlwaysCondition,
ApplicationCondition,
ApplicationRule,
Automation,
Condition,
DisplayStateCondition,
MQTTCondition,
StartupCondition,
SystemIdleCondition,
TimeOfDayCondition,
WebhookCondition,
DisplayStateRule,
MQTTRule,
Rule,
StartupRule,
SystemIdleRule,
TimeOfDayRule,
WebhookRule,
)
from wled_controller.storage.automation_store import AutomationStore
@@ -23,72 +22,78 @@ def store(tmp_db) -> AutomationStore:
# ---------------------------------------------------------------------------
# Condition models
# Rule models
# ---------------------------------------------------------------------------
class TestConditionModels:
def test_always_round_trip(self):
c = AlwaysCondition()
data = c.to_dict()
restored = Condition.from_dict(data)
assert isinstance(restored, AlwaysCondition)
class TestRuleModels:
def test_application_round_trip(self):
c = ApplicationCondition(apps=["chrome.exe", "firefox.exe"], match_type="topmost")
data = c.to_dict()
restored = Condition.from_dict(data)
assert isinstance(restored, ApplicationCondition)
r = ApplicationRule(apps=["chrome.exe", "firefox.exe"], match_type="topmost")
data = r.to_dict()
restored = Rule.from_dict(data)
assert isinstance(restored, ApplicationRule)
assert restored.apps == ["chrome.exe", "firefox.exe"]
assert restored.match_type == "topmost"
def test_time_of_day_round_trip(self):
c = TimeOfDayCondition(start_time="22:00", end_time="06:00")
data = c.to_dict()
restored = Condition.from_dict(data)
assert isinstance(restored, TimeOfDayCondition)
r = TimeOfDayRule(start_time="22:00", end_time="06:00")
data = r.to_dict()
restored = Rule.from_dict(data)
assert isinstance(restored, TimeOfDayRule)
assert restored.start_time == "22:00"
assert restored.end_time == "06:00"
def test_system_idle_round_trip(self):
c = SystemIdleCondition(idle_minutes=10, when_idle=False)
data = c.to_dict()
restored = Condition.from_dict(data)
assert isinstance(restored, SystemIdleCondition)
r = SystemIdleRule(idle_minutes=10, when_idle=False)
data = r.to_dict()
restored = Rule.from_dict(data)
assert isinstance(restored, SystemIdleRule)
assert restored.idle_minutes == 10
assert restored.when_idle is False
def test_display_state_round_trip(self):
c = DisplayStateCondition(state="off")
data = c.to_dict()
restored = Condition.from_dict(data)
assert isinstance(restored, DisplayStateCondition)
r = DisplayStateRule(state="off")
data = r.to_dict()
restored = Rule.from_dict(data)
assert isinstance(restored, DisplayStateRule)
assert restored.state == "off"
def test_mqtt_round_trip(self):
c = MQTTCondition(topic="home/tv", payload="on", match_mode="contains")
data = c.to_dict()
restored = Condition.from_dict(data)
assert isinstance(restored, MQTTCondition)
r = MQTTRule(topic="home/tv", payload="on", match_mode="contains")
data = r.to_dict()
restored = Rule.from_dict(data)
assert isinstance(restored, MQTTRule)
assert restored.topic == "home/tv"
assert restored.match_mode == "contains"
def test_webhook_round_trip(self):
c = WebhookCondition(token="abc123")
data = c.to_dict()
restored = Condition.from_dict(data)
assert isinstance(restored, WebhookCondition)
r = WebhookRule(token="abc123")
data = r.to_dict()
restored = Rule.from_dict(data)
assert isinstance(restored, WebhookRule)
assert restored.token == "abc123"
def test_startup_round_trip(self):
c = StartupCondition()
data = c.to_dict()
restored = Condition.from_dict(data)
assert isinstance(restored, StartupCondition)
r = StartupRule()
data = r.to_dict()
restored = Rule.from_dict(data)
assert isinstance(restored, StartupRule)
def test_unknown_condition_type_raises(self):
with pytest.raises(ValueError, match="Unknown condition type"):
Condition.from_dict({"condition_type": "nonexistent"})
def test_unknown_rule_type_raises(self):
with pytest.raises(ValueError, match="Unknown rule type"):
Rule.from_dict({"rule_type": "nonexistent"})
def test_legacy_condition_type_migration(self):
"""Legacy data with condition_type should still deserialize."""
data = {"condition_type": "startup"}
restored = Rule.from_dict(data)
assert isinstance(restored, StartupRule)
def test_legacy_always_maps_to_startup(self):
"""Legacy 'always' condition_type should map to StartupRule."""
data = {"condition_type": "always"}
restored = Rule.from_dict(data)
assert isinstance(restored, StartupRule)
# ---------------------------------------------------------------------------
@@ -100,7 +105,7 @@ class TestAutomationModel:
def test_round_trip(self, make_automation):
auto = make_automation(
name="Test Auto",
conditions=[AlwaysCondition(), WebhookCondition(token="tok1")],
rules=[StartupRule(), WebhookRule(token="tok1")],
scene_preset_id="sp_123",
deactivation_mode="revert",
)
@@ -109,21 +114,21 @@ class TestAutomationModel:
assert restored.id == auto.id
assert restored.name == "Test Auto"
assert len(restored.conditions) == 2
assert isinstance(restored.conditions[0], AlwaysCondition)
assert isinstance(restored.conditions[1], WebhookCondition)
assert len(restored.rules) == 2
assert isinstance(restored.rules[0], StartupRule)
assert isinstance(restored.rules[1], WebhookRule)
assert restored.scene_preset_id == "sp_123"
assert restored.deactivation_mode == "revert"
def test_from_dict_skips_unknown_conditions(self):
def test_from_dict_skips_unknown_rules(self):
data = {
"id": "a1",
"name": "Skip",
"enabled": True,
"condition_logic": "or",
"conditions": [
{"condition_type": "always"},
{"condition_type": "future_unknown"},
"rule_logic": "or",
"rules": [
{"rule_type": "startup"},
{"rule_type": "future_unknown"},
],
"scene_preset_id": None,
"deactivation_mode": "none",
@@ -132,7 +137,30 @@ class TestAutomationModel:
"updated_at": "2025-01-01T00:00:00+00:00",
}
auto = Automation.from_dict(data)
assert len(auto.conditions) == 1 # unknown was skipped
assert len(auto.rules) == 1 # unknown was skipped
def test_legacy_conditions_field_migration(self):
"""Legacy data with 'conditions' and 'condition_logic' should migrate."""
data = {
"id": "a2",
"name": "Legacy",
"enabled": True,
"condition_logic": "and",
"conditions": [
{"condition_type": "startup"},
{"condition_type": "application", "apps": ["test.exe"], "match_type": "running"},
],
"scene_preset_id": None,
"deactivation_mode": "none",
"deactivation_scene_preset_id": None,
"created_at": "2025-01-01T00:00:00+00:00",
"updated_at": "2025-01-01T00:00:00+00:00",
}
auto = Automation.from_dict(data)
assert auto.rule_logic == "and"
assert len(auto.rules) == 2
assert isinstance(auto.rules[0], StartupRule)
assert isinstance(auto.rules[1], ApplicationRule)
# ---------------------------------------------------------------------------
@@ -146,27 +174,27 @@ class TestAutomationStoreCRUD:
assert a.id.startswith("auto_")
assert a.name == "Auto A"
assert a.enabled is True
assert a.condition_logic == "or"
assert a.rule_logic == "or"
assert store.count() == 1
def test_create_with_conditions(self, store):
conditions = [
AlwaysCondition(),
WebhookCondition(token="secret123"),
def test_create_with_rules(self, store):
rules = [
StartupRule(),
WebhookRule(token="secret123"),
]
a = store.create_automation(
name="Full",
enabled=False,
condition_logic="and",
conditions=conditions,
rule_logic="and",
rules=rules,
scene_preset_id="sp_001",
deactivation_mode="fallback_scene",
deactivation_scene_preset_id="sp_002",
tags=["test"],
)
assert a.enabled is False
assert a.condition_logic == "and"
assert len(a.conditions) == 2
assert a.rule_logic == "and"
assert len(a.rules) == 2
assert a.scene_preset_id == "sp_001"
assert a.tags == ["test"]
@@ -195,12 +223,12 @@ class TestAutomationStoreCRUD:
assert updated.name == "New"
assert updated.enabled is False
def test_update_conditions(self, store):
a = store.create_automation(name="Conds")
new_conds = [ApplicationCondition(apps=["notepad.exe"])]
updated = store.update_automation(a.id, conditions=new_conds)
assert len(updated.conditions) == 1
assert isinstance(updated.conditions[0], ApplicationCondition)
def test_update_rules(self, store):
a = store.create_automation(name="Rules")
new_rules = [ApplicationRule(apps=["notepad.exe"])]
updated = store.update_automation(a.id, rules=new_rules)
assert len(updated.rules) == 1
assert isinstance(updated.rules[0], ApplicationRule)
def test_update_scene_preset_id_clear(self, store):
a = store.create_automation(name="SP", scene_preset_id="sp_1")
@@ -241,17 +269,18 @@ class TestAutomationNameUniqueness:
class TestAutomationPersistence:
def test_persist_and_reload(self, tmp_path):
from wled_controller.storage.database import Database
db = Database(tmp_path / "auto_persist.db")
s1 = AutomationStore(db)
a = s1.create_automation(
name="Persist",
conditions=[WebhookCondition(token="t1")],
rules=[WebhookRule(token="t1")],
)
aid = a.id
s2 = AutomationStore(db)
loaded = s2.get_automation(aid)
assert loaded.name == "Persist"
assert len(loaded.conditions) == 1
assert isinstance(loaded.conditions[0], WebhookCondition)
assert len(loaded.rules) == 1
assert isinstance(loaded.rules[0], WebhookRule)
db.close()
@@ -0,0 +1,274 @@
"""Tests for GameIntegrationStore — CRUD, validation, uniqueness."""
import pytest
from wled_controller.storage.base_store import EntityNotFoundError
from wled_controller.storage.game_integration import EventMapping, GameIntegrationConfig
from wled_controller.storage.game_integration_store import GameIntegrationStore
@pytest.fixture
def store(tmp_db) -> GameIntegrationStore:
return GameIntegrationStore(tmp_db)
# ---------------------------------------------------------------------------
# Dataclass model tests
# ---------------------------------------------------------------------------
class TestEventMapping:
def test_round_trip(self):
m = EventMapping(
event_type="health",
effect="pulse",
color=[0, 255, 0],
duration_ms=1000,
intensity=0.8,
priority=5,
)
data = m.to_dict()
restored = EventMapping.from_dict(data)
assert restored.event_type == "health"
assert restored.effect == "pulse"
assert restored.color == [0, 255, 0]
assert restored.duration_ms == 1000
assert restored.intensity == 0.8
assert restored.priority == 5
def test_defaults(self):
m = EventMapping(event_type="kill")
assert m.effect == "flash"
assert m.color == [255, 0, 0]
assert m.duration_ms == 500
assert m.intensity == 1.0
assert m.priority == 0
def test_from_dict_defaults(self):
m = EventMapping.from_dict({"event_type": "death"})
assert m.effect == "flash"
assert m.color == [255, 0, 0]
class TestGameIntegrationConfig:
def test_round_trip(self):
config = GameIntegrationConfig.create_from_kwargs(
name="CS2 Integration",
adapter_type="cs2_gsi",
enabled=True,
adapter_config={"token": "secret123"},
event_mappings=[
EventMapping(event_type="health", effect="gradient", color=[255, 0, 0]),
EventMapping(event_type="kill", effect="flash", color=[0, 255, 0]),
],
description="Counter-Strike 2 game state integration",
tags=["fps", "cs2"],
)
data = config.to_dict()
restored = GameIntegrationConfig.from_dict(data)
assert restored.id == config.id
assert restored.name == "CS2 Integration"
assert restored.adapter_type == "cs2_gsi"
assert restored.enabled is True
assert restored.adapter_config == {"token": "secret123"}
assert len(restored.event_mappings) == 2
assert restored.event_mappings[0].event_type == "health"
assert restored.event_mappings[1].event_type == "kill"
assert restored.description == "Counter-Strike 2 game state integration"
assert restored.tags == ["fps", "cs2"]
def test_create_from_kwargs_generates_id(self):
config = GameIntegrationConfig.create_from_kwargs(name="Test", adapter_type="test")
assert config.id.startswith("gi_")
assert len(config.id) == 11 # gi_ + 8 hex chars
def test_apply_update_immutable(self):
original = GameIntegrationConfig.create_from_kwargs(
name="Original", adapter_type="test", enabled=True
)
updated = original.apply_update(name="Updated", enabled=False)
# Original unchanged
assert original.name == "Original"
assert original.enabled is True
# Updated has new values
assert updated.name == "Updated"
assert updated.enabled is False
assert updated.id == original.id
assert updated.created_at == original.created_at
assert updated.updated_at >= original.updated_at
def test_apply_update_partial(self):
original = GameIntegrationConfig.create_from_kwargs(
name="Test", adapter_type="test", description="original desc"
)
updated = original.apply_update(description="new desc")
assert updated.name == "Test"
assert updated.adapter_type == "test"
assert updated.description == "new desc"
# ---------------------------------------------------------------------------
# Store CRUD tests
# ---------------------------------------------------------------------------
class TestGameIntegrationStoreCRUD:
def test_create_and_get(self, store):
config = store.create_integration(
name="My Integration",
adapter_type="webhook",
description="Test integration",
)
assert config.id.startswith("gi_")
assert config.name == "My Integration"
assert config.adapter_type == "webhook"
assert config.enabled is True
fetched = store.get_integration(config.id)
assert fetched.name == "My Integration"
def test_list_all(self, store):
store.create_integration(name="Int 1", adapter_type="webhook")
store.create_integration(name="Int 2", adapter_type="cs2_gsi")
all_configs = store.get_all_integrations()
assert len(all_configs) == 2
names = {c.name for c in all_configs}
assert names == {"Int 1", "Int 2"}
def test_update(self, store):
config = store.create_integration(
name="Old Name",
adapter_type="webhook",
)
updated = store.update_integration(
config.id,
name="New Name",
enabled=False,
description="Updated description",
)
assert updated.name == "New Name"
assert updated.enabled is False
assert updated.description == "Updated description"
assert updated.updated_at > config.updated_at
def test_update_event_mappings(self, store):
config = store.create_integration(
name="Test",
adapter_type="webhook",
event_mappings=[EventMapping(event_type="health")],
)
new_mappings = [
EventMapping(event_type="kill", effect="flash", color=[0, 255, 0]),
EventMapping(event_type="death", effect="pulse", color=[255, 0, 0]),
]
updated = store.update_integration(config.id, event_mappings=new_mappings)
assert len(updated.event_mappings) == 2
assert updated.event_mappings[0].event_type == "kill"
assert updated.event_mappings[1].event_type == "death"
def test_delete(self, store):
config = store.create_integration(name="ToDelete", adapter_type="webhook")
store.delete_integration(config.id)
with pytest.raises(EntityNotFoundError):
store.get_integration(config.id)
def test_delete_nonexistent(self, store):
with pytest.raises(EntityNotFoundError):
store.delete_integration("gi_nonexist")
def test_get_nonexistent(self, store):
with pytest.raises(EntityNotFoundError):
store.get_integration("gi_nonexist")
def test_create_with_adapter_config(self, store):
config = store.create_integration(
name="CS2",
adapter_type="cs2_gsi",
adapter_config={"auth_token": "secret", "port": 3000},
)
fetched = store.get_integration(config.id)
assert fetched.adapter_config == {"auth_token": "secret", "port": 3000}
# ---------------------------------------------------------------------------
# Name uniqueness tests
# ---------------------------------------------------------------------------
class TestNameUniqueness:
def test_duplicate_name_rejected(self, store):
store.create_integration(name="Unique Name", adapter_type="webhook")
with pytest.raises(ValueError, match="already exists"):
store.create_integration(name="Unique Name", adapter_type="webhook")
def test_empty_name_rejected(self, store):
with pytest.raises(ValueError, match="required"):
store.create_integration(name="", adapter_type="webhook")
def test_whitespace_name_rejected(self, store):
with pytest.raises(ValueError, match="required"):
store.create_integration(name=" ", adapter_type="webhook")
def test_update_same_name_allowed(self, store):
config = store.create_integration(name="Same", adapter_type="webhook")
updated = store.update_integration(config.id, name="Same")
assert updated.name == "Same"
def test_update_to_existing_name_rejected(self, store):
store.create_integration(name="Name A", adapter_type="webhook")
config_b = store.create_integration(name="Name B", adapter_type="webhook")
with pytest.raises(ValueError, match="already exists"):
store.update_integration(config_b.id, name="Name A")
# ---------------------------------------------------------------------------
# Persistence tests
# ---------------------------------------------------------------------------
class TestPersistence:
def test_survives_reload(self, tmp_db):
store1 = GameIntegrationStore(tmp_db)
config = store1.create_integration(
name="Persistent",
adapter_type="webhook",
event_mappings=[EventMapping(event_type="health", effect="gradient")],
tags=["test"],
)
# Create a new store instance (simulates restart)
store2 = GameIntegrationStore(tmp_db)
fetched = store2.get_integration(config.id)
assert fetched.name == "Persistent"
assert fetched.adapter_type == "webhook"
assert len(fetched.event_mappings) == 1
assert fetched.event_mappings[0].event_type == "health"
assert fetched.tags == ["test"]
# ---------------------------------------------------------------------------
# get_references tests
# ---------------------------------------------------------------------------
class TestGetReferences:
def test_returns_empty(self, store):
config = store.create_integration(name="Test", adapter_type="webhook")
refs = store.get_references(config.id)
assert refs == []