hyperion_controller.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349
  1. import requests
  2. import json
  3. from typing import Dict, Optional
  4. import time
  5. import logging
  6. logger = logging.getLogger(__name__)
  7. class HyperionController:
  8. """Controller for Hyperion LED system using JSON-RPC API"""
  9. def __init__(self, ip_address: Optional[str] = None, port: int = 8090):
  10. self.ip_address = ip_address
  11. self.port = port
  12. # Priority for Dune Weaver effects (lower = higher priority)
  13. # Using 100 to allow user to override with lower priorities if needed
  14. self.priority = 100
  15. def _get_base_url(self) -> str:
  16. """Get base URL for Hyperion JSON-RPC API"""
  17. if not self.ip_address:
  18. raise ValueError("No Hyperion IP configured")
  19. return f"http://{self.ip_address}:{self.port}/json-rpc"
  20. def set_ip(self, ip_address: str, port: int = 8090) -> None:
  21. """Update the Hyperion IP address and port"""
  22. self.ip_address = ip_address
  23. self.port = port
  24. def _send_command(self, command: str, **params) -> Dict:
  25. """Send JSON-RPC command to Hyperion and return response"""
  26. try:
  27. url = self._get_base_url()
  28. payload = {
  29. "command": command,
  30. **params
  31. }
  32. # Reduced timeout from 2s to 1s - Hyperion should respond quickly
  33. # This prevents hanging when Hyperion is under load
  34. response = requests.post(url, json=payload, timeout=1)
  35. response.raise_for_status()
  36. result = response.json()
  37. if not result.get("success", False):
  38. error_msg = result.get("error", "Unknown error")
  39. return {
  40. "connected": False,
  41. "message": f"Hyperion command failed: {error_msg}"
  42. }
  43. return {
  44. "connected": True,
  45. "message": "Command successful",
  46. "response": result
  47. }
  48. except ValueError as e:
  49. return {"connected": False, "message": str(e)}
  50. except requests.RequestException as e:
  51. return {"connected": False, "message": f"Cannot connect to Hyperion: {str(e)}"}
  52. except json.JSONDecodeError as e:
  53. return {"connected": False, "message": f"Error parsing Hyperion response: {str(e)}"}
  54. def check_hyperion_status(self) -> Dict:
  55. """Check Hyperion connection status, component state, and active priorities"""
  56. result = self._send_command("serverinfo")
  57. if result.get("connected"):
  58. response = result.get("response", {})
  59. info = response.get("info", {})
  60. components = {c["name"]: c["enabled"] for c in info.get("components", [])}
  61. # Get active priorities information
  62. priorities = info.get("priorities", [])
  63. active_priority = None
  64. active_effect = None
  65. active_color = None
  66. # Find the highest priority (lowest number) active source
  67. if priorities:
  68. # Filter for visible priorities only
  69. visible = [p for p in priorities if p.get("visible", True)]
  70. if visible:
  71. # Sort by priority (lowest first)
  72. visible.sort(key=lambda x: x.get("priority", 999))
  73. active_priority = visible[0].get("priority")
  74. # Check if it's our priority
  75. if active_priority == self.priority:
  76. component_id = visible[0].get("componentId", "")
  77. if component_id == "EFFECT":
  78. active_effect = visible[0].get("owner", "")
  79. elif component_id == "COLOR":
  80. active_color = visible[0].get("value", {}).get("RGB")
  81. return {
  82. "connected": True,
  83. "is_on": components.get("ALL", False),
  84. "ledstream_on": components.get("LEDDEVICE", False),
  85. "hostname": info.get("hostname", "unknown"),
  86. "version": info.get("version", "unknown"),
  87. "message": "Hyperion is ON" if components.get("ALL", False) else "Hyperion is OFF",
  88. "active_priority": active_priority,
  89. "active_effect": active_effect,
  90. "active_color": active_color,
  91. "our_priority_active": active_priority == self.priority if active_priority else False
  92. }
  93. return result
  94. def set_power(self, state: int, check_current: bool = True) -> Dict:
  95. """
  96. Set Hyperion power state (component control)
  97. Args:
  98. state: 0=Off, 1=On, 2=Toggle
  99. check_current: If True, check current state and skip if already in desired state
  100. """
  101. if state not in [0, 1, 2]:
  102. return {"connected": False, "message": "Power state must be 0 (Off), 1 (On), or 2 (Toggle)"}
  103. # Always check current state for toggle or when check_current is enabled
  104. if state == 2 or check_current:
  105. status = self.check_hyperion_status()
  106. if not status.get("connected"):
  107. return status
  108. current_state = status.get("is_on", False)
  109. if state == 2:
  110. # Toggle: flip the current state
  111. state = 0 if current_state else 1
  112. elif check_current:
  113. # Check if already in desired state
  114. desired_state = bool(state)
  115. if current_state == desired_state:
  116. logger.debug(f"Hyperion already {'ON' if desired_state else 'OFF'}, skipping power command")
  117. return {
  118. "connected": True,
  119. "message": f"Already in desired state ({'ON' if desired_state else 'OFF'})",
  120. "skipped": True
  121. }
  122. result = self._send_command(
  123. "componentstate",
  124. componentstate={
  125. "component": "ALL",
  126. "state": bool(state)
  127. }
  128. )
  129. return result
  130. def set_color(self, r: int = 0, g: int = 0, b: int = 0, duration: int = 86400000) -> Dict:
  131. """
  132. Set solid color on Hyperion
  133. Args:
  134. r, g, b: RGB values (0-255)
  135. duration: Duration in milliseconds (default = 86400000ms = 24 hours)
  136. Note: Some Hyperion instances don't support duration=0 for infinite
  137. """
  138. if not all(0 <= val <= 255 for val in [r, g, b]):
  139. return {"connected": False, "message": "RGB values must be between 0 and 255"}
  140. # Turn on Hyperion first
  141. self.set_power(1)
  142. # Clear priority before setting new color
  143. self.clear_priority()
  144. result = self._send_command(
  145. "color",
  146. priority=self.priority,
  147. color=[r, g, b],
  148. duration=duration
  149. )
  150. return result
  151. def set_effect(self, effect_name: str, args: Optional[Dict] = None, duration: int = 86400000, check_current: bool = True) -> Dict:
  152. """
  153. Set Hyperion effect
  154. Args:
  155. effect_name: Name of the effect (e.g., 'Rainbow swirl', 'Warm mood blobs')
  156. args: Optional effect arguments
  157. duration: Duration in milliseconds (default = 86400000ms = 24 hours)
  158. check_current: If True, check if effect is already active and skip if so
  159. """
  160. # Check current state if requested
  161. if check_current:
  162. status = self.check_hyperion_status()
  163. if not status.get("connected"):
  164. return status
  165. # Check if the same effect is already active at our priority
  166. if status.get("our_priority_active") and status.get("active_effect") == effect_name:
  167. logger.debug(f"Effect '{effect_name}' already active at our priority, skipping")
  168. return {
  169. "connected": True,
  170. "message": f"Effect '{effect_name}' already active",
  171. "skipped": True
  172. }
  173. # Ensure Hyperion is on (with state check)
  174. self.set_power(1, check_current=True)
  175. else:
  176. # Turn on without checking
  177. self.set_power(1, check_current=False)
  178. # Clear priority before setting new effect
  179. self.clear_priority()
  180. params = {
  181. "priority": self.priority,
  182. "effect": {"name": effect_name},
  183. "duration": duration
  184. }
  185. if args:
  186. params["effect"]["args"] = args
  187. result = self._send_command("effect", **params)
  188. return result
  189. def clear_priority(self, priority: Optional[int] = None, check_current: bool = True) -> Dict:
  190. """
  191. Clear a specific priority or Dune Weaver's priority
  192. Args:
  193. priority: Priority to clear (defaults to self.priority)
  194. check_current: If True, check if priority is active before clearing
  195. """
  196. if priority is None:
  197. priority = self.priority
  198. # Check if the priority is actually active
  199. if check_current:
  200. status = self.check_hyperion_status()
  201. if not status.get("connected"):
  202. return status
  203. # If our priority isn't active, no need to clear
  204. if priority == self.priority and not status.get("our_priority_active"):
  205. logger.debug(f"Priority {priority} not active, skipping clear")
  206. return {
  207. "connected": True,
  208. "message": f"Priority {priority} not active",
  209. "skipped": True
  210. }
  211. result = self._send_command("clear", priority=priority)
  212. return result
  213. def clear_all(self) -> Dict:
  214. """Clear all priorities (return to default state)"""
  215. result = self._send_command("clear", priority=-1)
  216. return result
  217. def set_brightness(self, value: int) -> Dict:
  218. """
  219. Set Hyperion brightness
  220. Args:
  221. value: Brightness (0-100)
  222. """
  223. if not 0 <= value <= 100:
  224. return {"connected": False, "message": "Brightness must be between 0 and 100"}
  225. result = self._send_command(
  226. "adjustment",
  227. adjustment={
  228. "brightness": value
  229. }
  230. )
  231. return result
  232. def effect_loading(hyperion_controller: HyperionController) -> bool:
  233. """Show loading effect - Atomic swirl effect"""
  234. try:
  235. # Set effect with smart checking (will check power state and current effect)
  236. res = hyperion_controller.set_effect("Atomic swirl", check_current=True)
  237. return res.get('connected', False)
  238. except Exception as e:
  239. logger.error(f"Error in effect_loading: {e}")
  240. return False
  241. def effect_idle(hyperion_controller: HyperionController, effect_name: str = "off") -> bool:
  242. """Show idle effect - use configured effect or clear priority to return to default
  243. Args:
  244. effect_name: Effect name to show, "off" to clear priority (default), or None for off
  245. """
  246. try:
  247. if effect_name and effect_name != "off":
  248. # Set effect with smart checking (will check power state and current effect)
  249. res = hyperion_controller.set_effect(effect_name, check_current=True)
  250. else:
  251. # Clear priority with smart checking (only if our priority is active)
  252. res = hyperion_controller.clear_priority(check_current=True)
  253. return res.get('connected', False)
  254. except Exception as e:
  255. logger.error(f"Error in effect_idle: {e}")
  256. return False
  257. def effect_connected(hyperion_controller: HyperionController) -> bool:
  258. """Show connected effect - green flash
  259. Note: This function only shows the connection flash. The calling code
  260. should explicitly set the idle effect afterwards to ensure the user's
  261. configured idle effect is used.
  262. """
  263. try:
  264. # Turn on Hyperion and clear in one go
  265. hyperion_controller.set_power(1)
  266. time.sleep(0.1) # Reduced blocking time
  267. hyperion_controller.clear_priority()
  268. # Single green flash instead of double - reduces load
  269. res = hyperion_controller.set_color(r=8, g=255, b=0, duration=1000)
  270. time.sleep(1.0) # Wait for flash to complete
  271. # Don't call effect_idle here - let the caller set the configured idle effect
  272. return res.get('connected', False)
  273. except Exception as e:
  274. logger.error(f"Error in effect_connected: {e}")
  275. return False
  276. def effect_playing(hyperion_controller: HyperionController, effect_name: str = "off") -> bool:
  277. """Show playing effect - use configured effect or clear to show default
  278. Args:
  279. effect_name: Effect name to show, "off" to clear priority (default), or None for off
  280. """
  281. try:
  282. if effect_name and effect_name != "off":
  283. # Set effect with smart checking (will check power state and current effect)
  284. res = hyperion_controller.set_effect(effect_name, check_current=True)
  285. else:
  286. # Clear priority with smart checking (only if our priority is active)
  287. res = hyperion_controller.clear_priority(check_current=True)
  288. return res.get('connected', False)
  289. except Exception as e:
  290. logger.error(f"Error in effect_playing: {e}")
  291. return False