dw_led_controller.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507
  1. """
  2. Dune Weaver LED Controller - Embedded NeoPixel LED controller for Raspberry Pi
  3. Provides direct GPIO control of WS2812B LED strips with beautiful effects
  4. """
  5. import threading
  6. import time
  7. import logging
  8. from typing import Optional, Dict, List, Tuple
  9. from .dw_leds.segment import Segment
  10. from .dw_leds.effects.basic_effects import get_effect, get_all_effects, FRAMETIME
  11. from .dw_leds.utils.palettes import get_palette_name, PALETTE_NAMES
  12. from .dw_leds.utils.colors import rgb_to_color
  13. logger = logging.getLogger(__name__)
  14. class DWLEDController:
  15. """Dune Weaver LED Controller for NeoPixel LED strips"""
  16. def __init__(self, num_leds: int = 60, gpio_pin: int = 12, brightness: float = 0.35,
  17. pixel_order: str = "GRB", speed: int = 128, intensity: int = 128):
  18. """
  19. Initialize Dune Weaver LED controller
  20. Args:
  21. num_leds: Number of LEDs in the strip
  22. gpio_pin: GPIO pin number (BCM numbering: 12, 13, 18, or 19)
  23. brightness: Global brightness (0.0 - 1.0)
  24. pixel_order: Pixel color order (GRB, RGB, RGBW, GRBW)
  25. speed: Effect speed 0-255 (default: 128)
  26. intensity: Effect intensity 0-255 (default: 128)
  27. """
  28. self.num_leds = num_leds
  29. self.gpio_pin = gpio_pin
  30. self.brightness = brightness
  31. self.pixel_order = pixel_order
  32. # State
  33. self._powered_on = False
  34. self._current_effect_id = 0
  35. self._current_palette_id = 0
  36. self._speed = speed
  37. self._intensity = intensity
  38. self._color1 = (255, 0, 0) # Red
  39. self._color2 = (0, 0, 255) # Blue
  40. self._color3 = (0, 255, 0) # Green
  41. # Threading
  42. self._pixels = None
  43. self._segment = None
  44. self._effect_thread = None
  45. self._stop_thread = threading.Event()
  46. self._lock = threading.Lock()
  47. self._initialized = False
  48. self._init_error = None # Store initialization error message
  49. def _initialize_hardware(self):
  50. """Lazy initialization of NeoPixel hardware"""
  51. if self._initialized:
  52. return True
  53. try:
  54. import board
  55. import neopixel
  56. # Map GPIO pin numbers to board pins
  57. pin_map = {
  58. 12: board.D12,
  59. 13: board.D13,
  60. 18: board.D18,
  61. 19: board.D19
  62. }
  63. if self.gpio_pin not in pin_map:
  64. error_msg = f"Invalid GPIO pin {self.gpio_pin}. Must be 12, 13, 18, or 19 (PWM-capable pins)"
  65. self._init_error = error_msg
  66. logger.error(error_msg)
  67. return False
  68. board_pin = pin_map[self.gpio_pin]
  69. # Initialize NeoPixel strip
  70. self._pixels = neopixel.NeoPixel(
  71. board_pin,
  72. self.num_leds,
  73. brightness=self.brightness,
  74. auto_write=False,
  75. pixel_order=self.pixel_order
  76. )
  77. # Create segment for the entire strip
  78. self._segment = Segment(self._pixels, 0, self.num_leds)
  79. self._segment.speed = self._speed
  80. self._segment.intensity = self._intensity
  81. self._segment.palette_id = self._current_palette_id
  82. # Set colors
  83. self._segment.colors[0] = rgb_to_color(*self._color1)
  84. self._segment.colors[1] = rgb_to_color(*self._color2)
  85. self._segment.colors[2] = rgb_to_color(*self._color3)
  86. self._initialized = True
  87. logger.info(f"DW LEDs initialized: {self.num_leds} LEDs on GPIO {self.gpio_pin}")
  88. return True
  89. except ImportError as e:
  90. error_msg = f"Failed to import NeoPixel libraries: {e}. Make sure adafruit-circuitpython-neopixel and Adafruit-Blinka are installed."
  91. self._init_error = error_msg
  92. logger.error(error_msg)
  93. return False
  94. except Exception as e:
  95. error_msg = f"Failed to initialize NeoPixel hardware: {e}"
  96. self._init_error = error_msg
  97. logger.error(error_msg)
  98. return False
  99. def _effect_loop(self):
  100. """Background thread that runs the current effect"""
  101. while not self._stop_thread.is_set():
  102. try:
  103. with self._lock:
  104. if self._pixels and self._segment and self._powered_on:
  105. # Get current effect function (allows dynamic effect switching)
  106. effect_func = get_effect(self._current_effect_id)
  107. # Run effect and get delay
  108. delay_ms = effect_func(self._segment)
  109. # Update pixels
  110. self._pixels.show()
  111. # Increment call counter
  112. self._segment.call += 1
  113. else:
  114. delay_ms = 100 # Idle delay when off
  115. # Sleep for the effect's requested delay
  116. time.sleep(delay_ms / 1000.0)
  117. except Exception as e:
  118. logger.error(f"Error in effect loop: {e}")
  119. time.sleep(0.1)
  120. def set_power(self, state: int) -> Dict:
  121. """
  122. Set power state
  123. Args:
  124. state: 0=Off, 1=On, 2=Toggle
  125. Returns:
  126. Dict with status
  127. """
  128. if not self._initialize_hardware():
  129. return {
  130. "connected": False,
  131. "error": self._init_error or "Failed to initialize LED hardware"
  132. }
  133. with self._lock:
  134. if state == 2: # Toggle
  135. self._powered_on = not self._powered_on
  136. else:
  137. self._powered_on = bool(state)
  138. # Turn off all pixels immediately when powering off
  139. if not self._powered_on and self._pixels:
  140. self._pixels.fill((0, 0, 0))
  141. self._pixels.show()
  142. # Start effect thread if not running
  143. if self._powered_on and (self._effect_thread is None or not self._effect_thread.is_alive()):
  144. self._stop_thread.clear()
  145. self._effect_thread = threading.Thread(target=self._effect_loop, daemon=True)
  146. self._effect_thread.start()
  147. return {
  148. "connected": True,
  149. "power_on": self._powered_on,
  150. "message": f"Power {'on' if self._powered_on else 'off'}"
  151. }
  152. def set_brightness(self, value: int) -> Dict:
  153. """
  154. Set global brightness
  155. Args:
  156. value: Brightness 0-100
  157. Returns:
  158. Dict with status
  159. """
  160. if not self._initialized:
  161. if not self._initialize_hardware():
  162. return {"connected": False, "error": self._init_error or "Hardware not initialized"}
  163. brightness = max(0.0, min(1.0, value / 100.0))
  164. with self._lock:
  165. self.brightness = brightness
  166. if self._pixels:
  167. self._pixels.brightness = brightness
  168. return {
  169. "connected": True,
  170. "brightness": int(brightness * 100),
  171. "message": "Brightness updated"
  172. }
  173. def set_color(self, r: int, g: int, b: int) -> Dict:
  174. """
  175. Set solid color (sets effect to Static and color1)
  176. Args:
  177. r, g, b: RGB values 0-255
  178. Returns:
  179. Dict with status
  180. """
  181. if not self._initialized:
  182. if not self._initialize_hardware():
  183. return {"connected": False, "error": self._init_error or "Hardware not initialized"}
  184. with self._lock:
  185. self._color1 = (r, g, b)
  186. if self._segment:
  187. self._segment.colors[0] = rgb_to_color(r, g, b)
  188. # Switch to static effect
  189. self._current_effect_id = 0
  190. self._segment.reset()
  191. # Auto power on when setting color
  192. if not self._powered_on:
  193. self._powered_on = True
  194. # Ensure effect thread is running
  195. if self._effect_thread is None or not self._effect_thread.is_alive():
  196. self._stop_thread.clear()
  197. self._effect_thread = threading.Thread(target=self._effect_loop, daemon=True)
  198. self._effect_thread.start()
  199. return {
  200. "connected": True,
  201. "color": [r, g, b],
  202. "power_on": self._powered_on,
  203. "message": "Color set"
  204. }
  205. def set_effect(self, effect_id: int, speed: Optional[int] = None,
  206. intensity: Optional[int] = None) -> Dict:
  207. """
  208. Set active effect
  209. Args:
  210. effect_id: Effect ID (0-15)
  211. speed: Optional speed override (0-255)
  212. intensity: Optional intensity override (0-255)
  213. Returns:
  214. Dict with status
  215. """
  216. if not self._initialized:
  217. if not self._initialize_hardware():
  218. return {"connected": False, "error": self._init_error or "Hardware not initialized"}
  219. # Validate effect ID
  220. effects = get_all_effects()
  221. if not any(eid == effect_id for eid, _ in effects):
  222. return {
  223. "connected": False,
  224. "message": f"Invalid effect ID: {effect_id}"
  225. }
  226. with self._lock:
  227. self._current_effect_id = effect_id
  228. if speed is not None:
  229. self._speed = max(0, min(255, speed))
  230. if self._segment:
  231. self._segment.speed = self._speed
  232. if intensity is not None:
  233. self._intensity = max(0, min(255, intensity))
  234. if self._segment:
  235. self._segment.intensity = self._intensity
  236. # Reset effect state
  237. if self._segment:
  238. self._segment.reset()
  239. # Auto power on when setting effect
  240. if not self._powered_on:
  241. self._powered_on = True
  242. # Ensure effect thread is running
  243. if self._effect_thread is None or not self._effect_thread.is_alive():
  244. self._stop_thread.clear()
  245. self._effect_thread = threading.Thread(target=self._effect_loop, daemon=True)
  246. self._effect_thread.start()
  247. effect_name = next(name for eid, name in effects if eid == effect_id)
  248. return {
  249. "connected": True,
  250. "effect_id": effect_id,
  251. "effect_name": effect_name,
  252. "power_on": self._powered_on,
  253. "message": f"Effect set to {effect_name}"
  254. }
  255. def set_palette(self, palette_id: int) -> Dict:
  256. """
  257. Set color palette
  258. Args:
  259. palette_id: Palette ID (0-58)
  260. Returns:
  261. Dict with status
  262. """
  263. if not self._initialized:
  264. if not self._initialize_hardware():
  265. return {"connected": False, "error": self._init_error or "Hardware not initialized"}
  266. if palette_id < 0 or palette_id >= len(PALETTE_NAMES):
  267. return {
  268. "connected": False,
  269. "message": f"Invalid palette ID: {palette_id}"
  270. }
  271. with self._lock:
  272. self._current_palette_id = palette_id
  273. if self._segment:
  274. self._segment.palette_id = palette_id
  275. # Auto power on when setting palette
  276. if not self._powered_on:
  277. self._powered_on = True
  278. # Ensure effect thread is running
  279. if self._effect_thread is None or not self._effect_thread.is_alive():
  280. self._stop_thread.clear()
  281. self._effect_thread = threading.Thread(target=self._effect_loop, daemon=True)
  282. self._effect_thread.start()
  283. palette_name = get_palette_name(palette_id)
  284. return {
  285. "connected": True,
  286. "palette_id": palette_id,
  287. "palette_name": palette_name,
  288. "power_on": self._powered_on,
  289. "message": f"Palette set to {palette_name}"
  290. }
  291. def set_speed(self, speed: int) -> Dict:
  292. """Set effect speed (0-255)"""
  293. if not self._initialized:
  294. if not self._initialize_hardware():
  295. return {"connected": False, "error": self._init_error or "Hardware not initialized"}
  296. speed = max(0, min(255, speed))
  297. with self._lock:
  298. self._speed = speed
  299. if self._segment:
  300. self._segment.speed = speed
  301. # Reset effect state so speed change takes effect immediately
  302. self._segment.reset()
  303. return {
  304. "connected": True,
  305. "speed": speed,
  306. "message": "Speed updated"
  307. }
  308. def set_intensity(self, intensity: int) -> Dict:
  309. """Set effect intensity (0-255)"""
  310. if not self._initialized:
  311. if not self._initialize_hardware():
  312. return {"connected": False, "error": self._init_error or "Hardware not initialized"}
  313. intensity = max(0, min(255, intensity))
  314. with self._lock:
  315. self._intensity = intensity
  316. if self._segment:
  317. self._segment.intensity = intensity
  318. # Reset effect state so intensity change takes effect immediately
  319. self._segment.reset()
  320. return {
  321. "connected": True,
  322. "intensity": intensity,
  323. "message": "Intensity updated"
  324. }
  325. def get_effects(self) -> List[Tuple[int, str]]:
  326. """Get list of all available effects"""
  327. return get_all_effects()
  328. def get_palettes(self) -> List[Tuple[int, str]]:
  329. """Get list of all available palettes"""
  330. return [(i, name) for i, name in enumerate(PALETTE_NAMES)]
  331. def check_status(self) -> Dict:
  332. """Get current controller status"""
  333. # Attempt initialization if not already initialized
  334. if not self._initialized:
  335. self._initialize_hardware()
  336. status = {
  337. "connected": self._initialized,
  338. "power_on": self._powered_on,
  339. "num_leds": self.num_leds,
  340. "gpio_pin": self.gpio_pin,
  341. "brightness": int(self.brightness * 100),
  342. "current_effect": self._current_effect_id,
  343. "current_palette": self._current_palette_id,
  344. "speed": self._speed,
  345. "intensity": self._intensity,
  346. "effect_running": self._effect_thread is not None and self._effect_thread.is_alive()
  347. }
  348. # Include error message if not initialized
  349. if not self._initialized and self._init_error:
  350. status["error"] = self._init_error
  351. return status
  352. def stop(self):
  353. """Stop the effect loop and cleanup"""
  354. self._stop_thread.set()
  355. if self._effect_thread and self._effect_thread.is_alive():
  356. self._effect_thread.join(timeout=1.0)
  357. with self._lock:
  358. if self._pixels:
  359. self._pixels.fill((0, 0, 0))
  360. self._pixels.show()
  361. self._pixels.deinit()
  362. self._pixels = None
  363. self._segment = None
  364. self._initialized = False
  365. # Helper functions for pattern manager integration
  366. def effect_loading(controller: DWLEDController) -> bool:
  367. """Show loading effect (Rainbow Cycle)"""
  368. try:
  369. controller.set_power(1)
  370. controller.set_effect(8, speed=100) # Rainbow Cycle
  371. return True
  372. except Exception as e:
  373. logger.error(f"Error setting loading effect: {e}")
  374. return False
  375. def effect_idle(controller: DWLEDController, effect_name: Optional[str] = None) -> bool:
  376. """Show idle effect"""
  377. try:
  378. if effect_name and effect_name.lower() != "off":
  379. # Try to find effect by name
  380. effects = controller.get_effects()
  381. for eid, name in effects:
  382. if name.lower() == effect_name.lower():
  383. controller.set_power(1)
  384. controller.set_effect(eid)
  385. return True
  386. # Default: turn off
  387. controller.set_power(0)
  388. return True
  389. except Exception as e:
  390. logger.error(f"Error setting idle effect: {e}")
  391. return False
  392. def effect_connected(controller: DWLEDController) -> bool:
  393. """Show connected effect (green flash)"""
  394. try:
  395. controller.set_power(1)
  396. controller.set_color(0, 255, 0) # Green
  397. controller.set_effect(1, speed=200, intensity=128) # Blink effect
  398. time.sleep(1.0)
  399. return True
  400. except Exception as e:
  401. logger.error(f"Error setting connected effect: {e}")
  402. return False
  403. def effect_playing(controller: DWLEDController, effect_name: Optional[str] = None) -> bool:
  404. """Show playing effect"""
  405. try:
  406. if effect_name and effect_name.lower() != "off":
  407. # Try to find effect by name
  408. effects = controller.get_effects()
  409. for eid, name in effects:
  410. if name.lower() == effect_name.lower():
  411. controller.set_power(1)
  412. controller.set_effect(eid)
  413. return True
  414. else:
  415. # Default: turn off
  416. controller.set_power(0)
  417. return True
  418. except Exception as e:
  419. logger.error(f"Error setting playing effect: {e}")
  420. return False