hyperion_controller.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283
  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 and component state"""
  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. return {
  62. "connected": True,
  63. "is_on": components.get("ALL", False),
  64. "ledstream_on": components.get("LEDDEVICE", False),
  65. "hostname": info.get("hostname", "unknown"),
  66. "version": info.get("version", "unknown"),
  67. "message": "Hyperion is ON" if components.get("ALL", False) else "Hyperion is OFF"
  68. }
  69. return result
  70. def set_power(self, state: int) -> Dict:
  71. """
  72. Set Hyperion power state (component control)
  73. Args:
  74. state: 0=Off, 1=On, 2=Toggle
  75. """
  76. if state not in [0, 1, 2]:
  77. return {"connected": False, "message": "Power state must be 0 (Off), 1 (On), or 2 (Toggle)"}
  78. if state == 2:
  79. # Get current state and toggle
  80. status = self.check_hyperion_status()
  81. if not status.get("connected"):
  82. return status
  83. state = 0 if status.get("is_on", False) else 1
  84. result = self._send_command(
  85. "componentstate",
  86. componentstate={
  87. "component": "ALL",
  88. "state": bool(state)
  89. }
  90. )
  91. return result
  92. def set_color(self, r: int = 0, g: int = 0, b: int = 0, duration: int = 86400000) -> Dict:
  93. """
  94. Set solid color on Hyperion
  95. Args:
  96. r, g, b: RGB values (0-255)
  97. duration: Duration in milliseconds (default = 86400000ms = 24 hours)
  98. Note: Some Hyperion instances don't support duration=0 for infinite
  99. """
  100. if not all(0 <= val <= 255 for val in [r, g, b]):
  101. return {"connected": False, "message": "RGB values must be between 0 and 255"}
  102. # Turn on Hyperion first
  103. self.set_power(1)
  104. # Clear priority before setting new color
  105. self.clear_priority()
  106. result = self._send_command(
  107. "color",
  108. priority=self.priority,
  109. color=[r, g, b],
  110. duration=duration
  111. )
  112. return result
  113. def set_effect(self, effect_name: str, args: Optional[Dict] = None, duration: int = 86400000) -> Dict:
  114. """
  115. Set Hyperion effect
  116. Args:
  117. effect_name: Name of the effect (e.g., 'Rainbow swirl', 'Warm mood blobs')
  118. args: Optional effect arguments
  119. duration: Duration in milliseconds (default = 86400000ms = 24 hours)
  120. """
  121. # Turn on Hyperion first
  122. self.set_power(1)
  123. # Clear priority before setting new effect
  124. self.clear_priority()
  125. params = {
  126. "priority": self.priority,
  127. "effect": {"name": effect_name},
  128. "duration": duration
  129. }
  130. if args:
  131. params["effect"]["args"] = args
  132. result = self._send_command("effect", **params)
  133. return result
  134. def clear_priority(self, priority: Optional[int] = None) -> Dict:
  135. """
  136. Clear a specific priority or Dune Weaver's priority
  137. Args:
  138. priority: Priority to clear (defaults to self.priority)
  139. """
  140. if priority is None:
  141. priority = self.priority
  142. result = self._send_command("clear", priority=priority)
  143. return result
  144. def clear_all(self) -> Dict:
  145. """Clear all priorities (return to default state)"""
  146. result = self._send_command("clear", priority=-1)
  147. return result
  148. def set_brightness(self, value: int) -> Dict:
  149. """
  150. Set Hyperion brightness
  151. Args:
  152. value: Brightness (0-100)
  153. """
  154. if not 0 <= value <= 100:
  155. return {"connected": False, "message": "Brightness must be between 0 and 100"}
  156. result = self._send_command(
  157. "adjustment",
  158. adjustment={
  159. "brightness": value
  160. }
  161. )
  162. return result
  163. def effect_loading(hyperion_controller: HyperionController) -> bool:
  164. """Show loading effect - Atomic swirl effect"""
  165. try:
  166. # Turn on Hyperion first
  167. hyperion_controller.set_power(1)
  168. time.sleep(0.1) # Reduced from 0.2s to minimize blocking
  169. # Clear priority before setting new effect
  170. hyperion_controller.clear_priority()
  171. # Removed unnecessary second sleep - Hyperion processes commands quickly
  172. res = hyperion_controller.set_effect("Atomic swirl")
  173. return res.get('connected', False)
  174. except Exception as e:
  175. logger.error(f"Error in effect_loading: {e}")
  176. return False
  177. def effect_idle(hyperion_controller: HyperionController, effect_name: str = "off") -> bool:
  178. """Show idle effect - use configured effect or clear priority to return to default
  179. Args:
  180. effect_name: Effect name to show, "off" to clear priority (default), or None for off
  181. """
  182. try:
  183. # Clear priority first - more efficient than power cycling
  184. hyperion_controller.clear_priority()
  185. if effect_name and effect_name != "off":
  186. # Turn on Hyperion and set effect
  187. hyperion_controller.set_power(1)
  188. time.sleep(0.05) # Minimal delay - Hyperion is fast
  189. res = hyperion_controller.set_effect(effect_name)
  190. else:
  191. # "off" or None - already cleared above, return to default state
  192. res = {"connected": True}
  193. return res.get('connected', False)
  194. except Exception as e:
  195. logger.error(f"Error in effect_idle: {e}")
  196. return False
  197. def effect_connected(hyperion_controller: HyperionController) -> bool:
  198. """Show connected effect - green flash
  199. Note: This function only shows the connection flash. The calling code
  200. should explicitly set the idle effect afterwards to ensure the user's
  201. configured idle effect is used.
  202. """
  203. try:
  204. # Turn on Hyperion and clear in one go
  205. hyperion_controller.set_power(1)
  206. time.sleep(0.1) # Reduced blocking time
  207. hyperion_controller.clear_priority()
  208. # Single green flash instead of double - reduces load
  209. res = hyperion_controller.set_color(r=8, g=255, b=0, duration=1000)
  210. time.sleep(1.0) # Wait for flash to complete
  211. # Don't call effect_idle here - let the caller set the configured idle effect
  212. return res.get('connected', False)
  213. except Exception as e:
  214. logger.error(f"Error in effect_connected: {e}")
  215. return False
  216. def effect_playing(hyperion_controller: HyperionController, effect_name: str = "off") -> bool:
  217. """Show playing effect - use configured effect or clear to show default
  218. Args:
  219. effect_name: Effect name to show, "off" to clear priority (default), or None for off
  220. """
  221. try:
  222. # Clear priority first - more efficient
  223. hyperion_controller.clear_priority()
  224. if effect_name and effect_name != "off":
  225. # Turn on Hyperion and set effect
  226. hyperion_controller.set_power(1)
  227. time.sleep(0.05) # Minimal delay
  228. res = hyperion_controller.set_effect(effect_name)
  229. else:
  230. # "off" or None - already cleared above, show user's configured effect/color
  231. res = {"connected": True}
  232. return res.get('connected', False)
  233. except Exception as e:
  234. logger.error(f"Error in effect_playing: {e}")
  235. return False