| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349 |
- import requests
- import json
- from typing import Dict, Optional
- import time
- import logging
- logger = logging.getLogger(__name__)
- class HyperionController:
- """Controller for Hyperion LED system using JSON-RPC API"""
- def __init__(self, ip_address: Optional[str] = None, port: int = 8090):
- self.ip_address = ip_address
- self.port = port
- # Priority for Dune Weaver effects (lower = higher priority)
- # Using 100 to allow user to override with lower priorities if needed
- self.priority = 100
- def _get_base_url(self) -> str:
- """Get base URL for Hyperion JSON-RPC API"""
- if not self.ip_address:
- raise ValueError("No Hyperion IP configured")
- return f"http://{self.ip_address}:{self.port}/json-rpc"
- def set_ip(self, ip_address: str, port: int = 8090) -> None:
- """Update the Hyperion IP address and port"""
- self.ip_address = ip_address
- self.port = port
- def _send_command(self, command: str, **params) -> Dict:
- """Send JSON-RPC command to Hyperion and return response"""
- try:
- url = self._get_base_url()
- payload = {
- "command": command,
- **params
- }
- # Reduced timeout from 2s to 1s - Hyperion should respond quickly
- # This prevents hanging when Hyperion is under load
- response = requests.post(url, json=payload, timeout=1)
- response.raise_for_status()
- result = response.json()
- if not result.get("success", False):
- error_msg = result.get("error", "Unknown error")
- return {
- "connected": False,
- "message": f"Hyperion command failed: {error_msg}"
- }
- return {
- "connected": True,
- "message": "Command successful",
- "response": result
- }
- except ValueError as e:
- return {"connected": False, "message": str(e)}
- except requests.RequestException as e:
- return {"connected": False, "message": f"Cannot connect to Hyperion: {str(e)}"}
- except json.JSONDecodeError as e:
- return {"connected": False, "message": f"Error parsing Hyperion response: {str(e)}"}
- def check_hyperion_status(self) -> Dict:
- """Check Hyperion connection status, component state, and active priorities"""
- result = self._send_command("serverinfo")
- if result.get("connected"):
- response = result.get("response", {})
- info = response.get("info", {})
- components = {c["name"]: c["enabled"] for c in info.get("components", [])}
- # Get active priorities information
- priorities = info.get("priorities", [])
- active_priority = None
- active_effect = None
- active_color = None
- # Find the highest priority (lowest number) active source
- if priorities:
- # Filter for visible priorities only
- visible = [p for p in priorities if p.get("visible", True)]
- if visible:
- # Sort by priority (lowest first)
- visible.sort(key=lambda x: x.get("priority", 999))
- active_priority = visible[0].get("priority")
- # Check if it's our priority
- if active_priority == self.priority:
- component_id = visible[0].get("componentId", "")
- if component_id == "EFFECT":
- active_effect = visible[0].get("owner", "")
- elif component_id == "COLOR":
- active_color = visible[0].get("value", {}).get("RGB")
- return {
- "connected": True,
- "is_on": components.get("ALL", False),
- "ledstream_on": components.get("LEDDEVICE", False),
- "hostname": info.get("hostname", "unknown"),
- "version": info.get("version", "unknown"),
- "message": "Hyperion is ON" if components.get("ALL", False) else "Hyperion is OFF",
- "active_priority": active_priority,
- "active_effect": active_effect,
- "active_color": active_color,
- "our_priority_active": active_priority == self.priority if active_priority else False
- }
- return result
- def set_power(self, state: int, check_current: bool = True) -> Dict:
- """
- Set Hyperion power state (component control)
- Args:
- state: 0=Off, 1=On, 2=Toggle
- check_current: If True, check current state and skip if already in desired state
- """
- if state not in [0, 1, 2]:
- return {"connected": False, "message": "Power state must be 0 (Off), 1 (On), or 2 (Toggle)"}
- # Always check current state for toggle or when check_current is enabled
- if state == 2 or check_current:
- status = self.check_hyperion_status()
- if not status.get("connected"):
- return status
- current_state = status.get("is_on", False)
- if state == 2:
- # Toggle: flip the current state
- state = 0 if current_state else 1
- elif check_current:
- # Check if already in desired state
- desired_state = bool(state)
- if current_state == desired_state:
- logger.debug(f"Hyperion already {'ON' if desired_state else 'OFF'}, skipping power command")
- return {
- "connected": True,
- "message": f"Already in desired state ({'ON' if desired_state else 'OFF'})",
- "skipped": True
- }
- result = self._send_command(
- "componentstate",
- componentstate={
- "component": "ALL",
- "state": bool(state)
- }
- )
- return result
- def set_color(self, r: int = 0, g: int = 0, b: int = 0, duration: int = 86400000) -> Dict:
- """
- Set solid color on Hyperion
- Args:
- r, g, b: RGB values (0-255)
- duration: Duration in milliseconds (default = 86400000ms = 24 hours)
- Note: Some Hyperion instances don't support duration=0 for infinite
- """
- if not all(0 <= val <= 255 for val in [r, g, b]):
- return {"connected": False, "message": "RGB values must be between 0 and 255"}
- # Turn on Hyperion first
- self.set_power(1)
- # Clear priority before setting new color
- self.clear_priority()
- result = self._send_command(
- "color",
- priority=self.priority,
- color=[r, g, b],
- duration=duration
- )
- return result
- def set_effect(self, effect_name: str, args: Optional[Dict] = None, duration: int = 86400000, check_current: bool = True) -> Dict:
- """
- Set Hyperion effect
- Args:
- effect_name: Name of the effect (e.g., 'Rainbow swirl', 'Warm mood blobs')
- args: Optional effect arguments
- duration: Duration in milliseconds (default = 86400000ms = 24 hours)
- check_current: If True, check if effect is already active and skip if so
- """
- # Check current state if requested
- if check_current:
- status = self.check_hyperion_status()
- if not status.get("connected"):
- return status
- # Check if the same effect is already active at our priority
- if status.get("our_priority_active") and status.get("active_effect") == effect_name:
- logger.debug(f"Effect '{effect_name}' already active at our priority, skipping")
- return {
- "connected": True,
- "message": f"Effect '{effect_name}' already active",
- "skipped": True
- }
- # Ensure Hyperion is on (with state check)
- self.set_power(1, check_current=True)
- else:
- # Turn on without checking
- self.set_power(1, check_current=False)
- # Clear priority before setting new effect
- self.clear_priority()
- params = {
- "priority": self.priority,
- "effect": {"name": effect_name},
- "duration": duration
- }
- if args:
- params["effect"]["args"] = args
- result = self._send_command("effect", **params)
- return result
- def clear_priority(self, priority: Optional[int] = None, check_current: bool = True) -> Dict:
- """
- Clear a specific priority or Dune Weaver's priority
- Args:
- priority: Priority to clear (defaults to self.priority)
- check_current: If True, check if priority is active before clearing
- """
- if priority is None:
- priority = self.priority
- # Check if the priority is actually active
- if check_current:
- status = self.check_hyperion_status()
- if not status.get("connected"):
- return status
- # If our priority isn't active, no need to clear
- if priority == self.priority and not status.get("our_priority_active"):
- logger.debug(f"Priority {priority} not active, skipping clear")
- return {
- "connected": True,
- "message": f"Priority {priority} not active",
- "skipped": True
- }
- result = self._send_command("clear", priority=priority)
- return result
- def clear_all(self) -> Dict:
- """Clear all priorities (return to default state)"""
- result = self._send_command("clear", priority=-1)
- return result
- def set_brightness(self, value: int) -> Dict:
- """
- Set Hyperion brightness
- Args:
- value: Brightness (0-100)
- """
- if not 0 <= value <= 100:
- return {"connected": False, "message": "Brightness must be between 0 and 100"}
- result = self._send_command(
- "adjustment",
- adjustment={
- "brightness": value
- }
- )
- return result
- def effect_loading(hyperion_controller: HyperionController) -> bool:
- """Show loading effect - Atomic swirl effect"""
- try:
- # Set effect with smart checking (will check power state and current effect)
- res = hyperion_controller.set_effect("Atomic swirl", check_current=True)
- return res.get('connected', False)
- except Exception as e:
- logger.error(f"Error in effect_loading: {e}")
- return False
- def effect_idle(hyperion_controller: HyperionController, effect_name: str = "off") -> bool:
- """Show idle effect - use configured effect or clear priority to return to default
- Args:
- effect_name: Effect name to show, "off" to clear priority (default), or None for off
- """
- try:
- if effect_name and effect_name != "off":
- # Set effect with smart checking (will check power state and current effect)
- res = hyperion_controller.set_effect(effect_name, check_current=True)
- else:
- # Clear priority with smart checking (only if our priority is active)
- res = hyperion_controller.clear_priority(check_current=True)
- return res.get('connected', False)
- except Exception as e:
- logger.error(f"Error in effect_idle: {e}")
- return False
- def effect_connected(hyperion_controller: HyperionController) -> bool:
- """Show connected effect - green flash
- Note: This function only shows the connection flash. The calling code
- should explicitly set the idle effect afterwards to ensure the user's
- configured idle effect is used.
- """
- try:
- # Turn on Hyperion and clear in one go
- hyperion_controller.set_power(1)
- time.sleep(0.1) # Reduced blocking time
- hyperion_controller.clear_priority()
- # Single green flash instead of double - reduces load
- res = hyperion_controller.set_color(r=8, g=255, b=0, duration=1000)
- time.sleep(1.0) # Wait for flash to complete
- # Don't call effect_idle here - let the caller set the configured idle effect
- return res.get('connected', False)
- except Exception as e:
- logger.error(f"Error in effect_connected: {e}")
- return False
- def effect_playing(hyperion_controller: HyperionController, effect_name: str = "off") -> bool:
- """Show playing effect - use configured effect or clear to show default
- Args:
- effect_name: Effect name to show, "off" to clear priority (default), or None for off
- """
- try:
- if effect_name and effect_name != "off":
- # Set effect with smart checking (will check power state and current effect)
- res = hyperion_controller.set_effect(effect_name, check_current=True)
- else:
- # Clear priority with smart checking (only if our priority is active)
- res = hyperion_controller.clear_priority(check_current=True)
- return res.get('connected', False)
- except Exception as e:
- logger.error(f"Error in effect_playing: {e}")
- return False
|