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