dw_led_controller.py 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672
  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 = 18, 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 = 8
  35. self._current_palette_id = 0
  36. self._speed = speed
  37. self._intensity = intensity
  38. self._color1 = (255, 0, 0) # Red (primary)
  39. self._color2 = (0, 0, 0) # Black (background/off)
  40. self._color3 = (0, 0, 255) # Blue (tertiary)
  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 standard NeoPixel library first (works on Pi 4 and earlier)
  54. # If that fails, fall back to Pi 5-specific library
  55. neopixel_module = None
  56. using_pi5_library = False
  57. try:
  58. import board
  59. import neopixel
  60. neopixel_module = neopixel
  61. logger.info("Using standard NeoPixel library")
  62. except (ImportError, RuntimeError) as e:
  63. logger.warning(f"Standard NeoPixel library failed: {e}. Trying Pi 5 library...")
  64. try:
  65. import board
  66. from adafruit_blinka_raspberry_pi5_neopixel import neopixel as neopixel_pi5
  67. neopixel_module = neopixel_pi5
  68. using_pi5_library = True
  69. logger.info("Using Adafruit Pi 5 NeoPixel library (PIO-based)")
  70. except ImportError as e2:
  71. error_msg = (
  72. f"Failed to import NeoPixel libraries. "
  73. f"Standard library error: {e}. "
  74. f"Pi 5 library error: {e2}. "
  75. f"For Pi 4 and earlier: pip install adafruit-circuitpython-neopixel adafruit-blinka. "
  76. f"For Pi 5: pip install Adafruit-Blinka-Raspberry-Pi5-Neopixel"
  77. )
  78. self._init_error = error_msg
  79. logger.error(error_msg)
  80. return False
  81. try:
  82. # Map GPIO pin numbers to board pins
  83. pin_map = {
  84. 12: board.D12,
  85. 13: board.D13,
  86. 18: board.D18,
  87. 19: board.D19
  88. }
  89. if self.gpio_pin not in pin_map:
  90. error_msg = f"Invalid GPIO pin {self.gpio_pin}. Must be 12, 13, 18, or 19 (PWM-capable pins)"
  91. self._init_error = error_msg
  92. logger.error(error_msg)
  93. return False
  94. board_pin = pin_map[self.gpio_pin]
  95. # Initialize NeoPixel strip
  96. pixels = neopixel_module.NeoPixel(
  97. board_pin,
  98. self.num_leds,
  99. brightness=self.brightness,
  100. auto_write=False,
  101. pixel_order=self.pixel_order
  102. )
  103. # Test that .show() actually works — the constructor succeeds without
  104. # root, but .show() needs DMA via /dev/mem. If it fails here we get a
  105. # clean Python exception; if we let the effect loop hit it first the
  106. # underlying C library can SEGV and crash the whole process.
  107. try:
  108. pixels.show()
  109. except Exception as e:
  110. pixels.deinit()
  111. error_msg = f"NeoPixel hardware test failed (likely needs root): {e}"
  112. self._init_error = error_msg
  113. logger.warning(error_msg)
  114. return False
  115. self._pixels = pixels
  116. # Create segment for the entire strip
  117. self._segment = Segment(self._pixels, 0, self.num_leds)
  118. self._segment.speed = self._speed
  119. self._segment.intensity = self._intensity
  120. self._segment.palette_id = self._current_palette_id
  121. # Set colors
  122. self._segment.colors[0] = rgb_to_color(*self._color1)
  123. self._segment.colors[1] = rgb_to_color(*self._color2)
  124. self._segment.colors[2] = rgb_to_color(*self._color3)
  125. self._initialized = True
  126. library_type = "Pi 5 (PIO)" if using_pi5_library else "standard"
  127. logger.info(f"DW LEDs initialized: {self.num_leds} LEDs on GPIO {self.gpio_pin} using {library_type} library")
  128. return True
  129. except Exception as e:
  130. error_msg = f"Failed to initialize NeoPixel hardware: {e}"
  131. self._init_error = error_msg
  132. logger.error(error_msg)
  133. return False
  134. def _effect_loop(self):
  135. """Background thread that runs the current effect"""
  136. while not self._stop_thread.is_set():
  137. try:
  138. with self._lock:
  139. if self._pixels and self._segment and self._powered_on:
  140. # Get current effect function (allows dynamic effect switching)
  141. effect_func = get_effect(self._current_effect_id)
  142. # Run effect and get delay
  143. delay_ms = effect_func(self._segment)
  144. # Update pixels
  145. self._pixels.show()
  146. # Increment call counter
  147. self._segment.call += 1
  148. else:
  149. delay_ms = 100 # Idle delay when off
  150. # Sleep for the effect's requested delay
  151. time.sleep(delay_ms / 1000.0)
  152. except Exception as e:
  153. logger.error(f"Error in effect loop: {e}")
  154. time.sleep(0.1)
  155. def set_power(self, state: int) -> Dict:
  156. """
  157. Set power state
  158. Args:
  159. state: 0=Off, 1=On, 2=Toggle
  160. Returns:
  161. Dict with status
  162. """
  163. if not self._initialize_hardware():
  164. return {
  165. "connected": False,
  166. "error": self._init_error or "Failed to initialize LED hardware"
  167. }
  168. with self._lock:
  169. if state == 2: # Toggle
  170. self._powered_on = not self._powered_on
  171. else:
  172. self._powered_on = bool(state)
  173. # Turn off all pixels immediately when powering off
  174. if not self._powered_on and self._pixels:
  175. self._pixels.fill((0, 0, 0))
  176. self._pixels.show()
  177. # Start effect thread if not running
  178. if self._powered_on and (self._effect_thread is None or not self._effect_thread.is_alive()):
  179. self._stop_thread.clear()
  180. self._effect_thread = threading.Thread(target=self._effect_loop, daemon=True)
  181. self._effect_thread.start()
  182. return {
  183. "connected": True,
  184. "power_on": self._powered_on,
  185. "message": f"Power {'on' if self._powered_on else 'off'}"
  186. }
  187. def set_brightness(self, value: int) -> Dict:
  188. """
  189. Set global brightness
  190. Args:
  191. value: Brightness 0-100
  192. Returns:
  193. Dict with status
  194. """
  195. if not self._initialized:
  196. if not self._initialize_hardware():
  197. return {"connected": False, "error": self._init_error or "Hardware not initialized"}
  198. brightness = max(0.0, min(1.0, value / 100.0))
  199. with self._lock:
  200. self.brightness = brightness
  201. if self._pixels:
  202. self._pixels.brightness = brightness
  203. return {
  204. "connected": True,
  205. "brightness": int(brightness * 100),
  206. "message": "Brightness updated"
  207. }
  208. def set_color(self, r: int, g: int, b: int) -> Dict:
  209. """
  210. Set solid color (sets effect to Static and color1)
  211. Args:
  212. r, g, b: RGB values 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. with self._lock:
  220. self._color1 = (r, g, b)
  221. if self._segment:
  222. self._segment.colors[0] = rgb_to_color(r, g, b)
  223. # Switch to static effect
  224. self._current_effect_id = 0
  225. self._segment.reset()
  226. # Auto power on when setting color
  227. if not self._powered_on:
  228. self._powered_on = True
  229. # Ensure effect thread is running
  230. if self._effect_thread is None or not self._effect_thread.is_alive():
  231. self._stop_thread.clear()
  232. self._effect_thread = threading.Thread(target=self._effect_loop, daemon=True)
  233. self._effect_thread.start()
  234. return {
  235. "connected": True,
  236. "color": [r, g, b],
  237. "power_on": self._powered_on,
  238. "message": "Color set"
  239. }
  240. def set_colors(self, color1: Optional[Tuple[int, int, int]] = None,
  241. color2: Optional[Tuple[int, int, int]] = None,
  242. color3: Optional[Tuple[int, int, int]] = None) -> Dict:
  243. """
  244. Set effect colors (does not change effect or auto-power on)
  245. Args:
  246. color1: Primary color RGB tuple (0-255)
  247. color2: Secondary/background color RGB tuple (0-255)
  248. color3: Tertiary color RGB tuple (0-255)
  249. Returns:
  250. Dict with status
  251. """
  252. if not self._initialized:
  253. if not self._initialize_hardware():
  254. return {"connected": False, "error": self._init_error or "Hardware not initialized"}
  255. colors_set = []
  256. with self._lock:
  257. if color1 is not None:
  258. self._color1 = color1
  259. if self._segment:
  260. self._segment.colors[0] = rgb_to_color(*color1)
  261. colors_set.append(f"color1={color1}")
  262. if color2 is not None:
  263. self._color2 = color2
  264. if self._segment:
  265. self._segment.colors[1] = rgb_to_color(*color2)
  266. colors_set.append(f"color2={color2}")
  267. if color3 is not None:
  268. self._color3 = color3
  269. if self._segment:
  270. self._segment.colors[2] = rgb_to_color(*color3)
  271. colors_set.append(f"color3={color3}")
  272. # Reset effect to apply new colors
  273. if self._segment and colors_set:
  274. self._segment.reset()
  275. return {
  276. "connected": True,
  277. "colors": {
  278. "color1": self._color1,
  279. "color2": self._color2,
  280. "color3": self._color3
  281. },
  282. "message": f"Colors updated: {', '.join(colors_set)}"
  283. }
  284. def set_effect(self, effect_id: int, speed: Optional[int] = None,
  285. intensity: Optional[int] = None) -> Dict:
  286. """
  287. Set active effect
  288. Args:
  289. effect_id: Effect ID (0-15)
  290. speed: Optional speed override (0-255)
  291. intensity: Optional intensity override (0-255)
  292. Returns:
  293. Dict with status
  294. """
  295. if not self._initialized:
  296. if not self._initialize_hardware():
  297. return {"connected": False, "error": self._init_error or "Hardware not initialized"}
  298. # Validate effect ID
  299. effects = get_all_effects()
  300. if not any(eid == effect_id for eid, _ in effects):
  301. return {
  302. "connected": False,
  303. "message": f"Invalid effect ID: {effect_id}"
  304. }
  305. with self._lock:
  306. self._current_effect_id = effect_id
  307. if speed is not None:
  308. self._speed = max(0, min(255, speed))
  309. if self._segment:
  310. self._segment.speed = self._speed
  311. if intensity is not None:
  312. self._intensity = max(0, min(255, intensity))
  313. if self._segment:
  314. self._segment.intensity = self._intensity
  315. # Reset effect state
  316. if self._segment:
  317. self._segment.reset()
  318. # Auto power on when setting effect
  319. if not self._powered_on:
  320. self._powered_on = True
  321. # Ensure effect thread is running
  322. if self._effect_thread is None or not self._effect_thread.is_alive():
  323. self._stop_thread.clear()
  324. self._effect_thread = threading.Thread(target=self._effect_loop, daemon=True)
  325. self._effect_thread.start()
  326. effect_name = next(name for eid, name in effects if eid == effect_id)
  327. return {
  328. "connected": True,
  329. "effect_id": effect_id,
  330. "effect_name": effect_name,
  331. "power_on": self._powered_on,
  332. "message": f"Effect set to {effect_name}"
  333. }
  334. def set_palette(self, palette_id: int) -> Dict:
  335. """
  336. Set color palette
  337. Args:
  338. palette_id: Palette ID (0-58)
  339. Returns:
  340. Dict with status
  341. """
  342. if not self._initialized:
  343. if not self._initialize_hardware():
  344. return {"connected": False, "error": self._init_error or "Hardware not initialized"}
  345. if palette_id < 0 or palette_id >= len(PALETTE_NAMES):
  346. return {
  347. "connected": False,
  348. "message": f"Invalid palette ID: {palette_id}"
  349. }
  350. with self._lock:
  351. self._current_palette_id = palette_id
  352. if self._segment:
  353. self._segment.palette_id = palette_id
  354. # Auto power on when setting palette
  355. if not self._powered_on:
  356. self._powered_on = True
  357. # Ensure effect thread is running
  358. if self._effect_thread is None or not self._effect_thread.is_alive():
  359. self._stop_thread.clear()
  360. self._effect_thread = threading.Thread(target=self._effect_loop, daemon=True)
  361. self._effect_thread.start()
  362. palette_name = get_palette_name(palette_id)
  363. return {
  364. "connected": True,
  365. "palette_id": palette_id,
  366. "palette_name": palette_name,
  367. "power_on": self._powered_on,
  368. "message": f"Palette set to {palette_name}"
  369. }
  370. def set_speed(self, speed: int) -> Dict:
  371. """Set effect speed (0-255)"""
  372. if not self._initialized:
  373. if not self._initialize_hardware():
  374. return {"connected": False, "error": self._init_error or "Hardware not initialized"}
  375. speed = max(0, min(255, speed))
  376. with self._lock:
  377. self._speed = speed
  378. if self._segment:
  379. self._segment.speed = speed
  380. # Reset effect state so speed change takes effect immediately
  381. self._segment.reset()
  382. return {
  383. "connected": True,
  384. "speed": speed,
  385. "message": "Speed updated"
  386. }
  387. def set_intensity(self, intensity: int) -> Dict:
  388. """Set effect intensity (0-255)"""
  389. if not self._initialized:
  390. if not self._initialize_hardware():
  391. return {"connected": False, "error": self._init_error or "Hardware not initialized"}
  392. intensity = max(0, min(255, intensity))
  393. with self._lock:
  394. self._intensity = intensity
  395. if self._segment:
  396. self._segment.intensity = intensity
  397. # Reset effect state so intensity change takes effect immediately
  398. self._segment.reset()
  399. return {
  400. "connected": True,
  401. "intensity": intensity,
  402. "message": "Intensity updated"
  403. }
  404. def get_effects(self) -> List[Tuple[int, str]]:
  405. """Get list of all available effects"""
  406. return get_all_effects()
  407. def get_palettes(self) -> List[Tuple[int, str]]:
  408. """Get list of all available palettes"""
  409. return [(i, name) for i, name in enumerate(PALETTE_NAMES)]
  410. def check_status(self) -> Dict:
  411. """Get current controller status"""
  412. # Attempt initialization if not already initialized
  413. if not self._initialized:
  414. self._initialize_hardware()
  415. # Get color slots from segment if available
  416. colors = []
  417. if self._segment and hasattr(self._segment, 'colors'):
  418. for color_int in self._segment.colors[:3]: # Get up to 3 colors
  419. # Convert integer color to hex string
  420. r = (color_int >> 16) & 0xFF
  421. g = (color_int >> 8) & 0xFF
  422. b = color_int & 0xFF
  423. colors.append(f"#{r:02x}{g:02x}{b:02x}")
  424. else:
  425. colors = ["#ff0000", "#000000", "#0000ff"] # Defaults
  426. status = {
  427. "connected": self._initialized,
  428. "power_on": self._powered_on,
  429. "num_leds": self.num_leds,
  430. "gpio_pin": self.gpio_pin,
  431. "brightness": int(self.brightness * 100),
  432. "current_effect": self._current_effect_id,
  433. "current_palette": self._current_palette_id,
  434. "speed": self._speed,
  435. "intensity": self._intensity,
  436. "colors": colors,
  437. "effect_running": self._effect_thread is not None and self._effect_thread.is_alive()
  438. }
  439. # Include error message if not initialized
  440. if not self._initialized and self._init_error:
  441. status["error"] = self._init_error
  442. return status
  443. def stop(self):
  444. """Stop the effect loop and cleanup"""
  445. self._stop_thread.set()
  446. if self._effect_thread and self._effect_thread.is_alive():
  447. self._effect_thread.join(timeout=1.0)
  448. with self._lock:
  449. if self._pixels:
  450. self._pixels.fill((0, 0, 0))
  451. self._pixels.show()
  452. self._pixels.deinit()
  453. self._pixels = None
  454. self._segment = None
  455. self._initialized = False
  456. # Helper functions for pattern manager integration
  457. def effect_loading(controller: DWLEDController) -> bool:
  458. """Show loading effect (Rainbow Cycle)"""
  459. try:
  460. controller.set_power(1)
  461. controller.set_effect(8, speed=100) # Rainbow Cycle
  462. return True
  463. except Exception as e:
  464. logger.error(f"Error setting loading effect: {e}")
  465. return False
  466. def effect_idle(controller: DWLEDController, effect_settings: Optional[dict] = None) -> bool:
  467. """Show idle effect with full settings. If no effect configured, plays Rainbow with current parameters."""
  468. try:
  469. controller.set_power(1)
  470. if effect_settings and isinstance(effect_settings, dict):
  471. # Configured idle effect: apply full settings
  472. effect_id = effect_settings.get("effect_id", 0)
  473. palette_id = effect_settings.get("palette_id", 0)
  474. speed = effect_settings.get("speed", 128)
  475. intensity = effect_settings.get("intensity", 128)
  476. controller.set_effect(effect_id, speed=speed, intensity=intensity)
  477. controller.set_palette(palette_id)
  478. # Set colors if provided
  479. color1 = effect_settings.get("color1")
  480. if color1:
  481. # Convert hex to RGB
  482. r1 = int(color1[1:3], 16)
  483. g1 = int(color1[3:5], 16)
  484. b1 = int(color1[5:7], 16)
  485. color2 = effect_settings.get("color2", "#000000")
  486. r2 = int(color2[1:3], 16)
  487. g2 = int(color2[3:5], 16)
  488. b2 = int(color2[5:7], 16)
  489. color3 = effect_settings.get("color3", "#0000ff")
  490. r3 = int(color3[1:3], 16)
  491. g3 = int(color3[3:5], 16)
  492. b3 = int(color3[5:7], 16)
  493. controller.set_colors(
  494. color1=(r1, g1, b1),
  495. color2=(r2, g2, b2),
  496. color3=(r3, g3, b3)
  497. )
  498. else:
  499. # Default: Rainbow effect with speed 60 for smoother animation
  500. controller.set_effect(8, speed=60, intensity=controller._intensity)
  501. controller.set_colors(
  502. color1=controller._color1,
  503. color2=controller._color2,
  504. color3=controller._color3
  505. )
  506. return True
  507. except Exception as e:
  508. logger.error(f"Error setting idle effect: {e}")
  509. return False
  510. def effect_connected(controller: DWLEDController) -> bool:
  511. """Show connected effect (green flash)"""
  512. try:
  513. controller.set_power(1)
  514. controller.set_color(0, 255, 0) # Green
  515. controller.set_effect(1, speed=200, intensity=128) # Blink effect
  516. time.sleep(1.0)
  517. return True
  518. except Exception as e:
  519. logger.error(f"Error setting connected effect: {e}")
  520. return False
  521. def effect_playing(controller: DWLEDController, effect_settings: Optional[dict] = None) -> bool:
  522. """Show playing effect with full settings"""
  523. try:
  524. if effect_settings and isinstance(effect_settings, dict):
  525. # New format: full settings dict
  526. controller.set_power(1)
  527. # Set effect
  528. effect_id = effect_settings.get("effect_id", 0)
  529. palette_id = effect_settings.get("palette_id", 0)
  530. speed = effect_settings.get("speed", 128)
  531. intensity = effect_settings.get("intensity", 128)
  532. controller.set_effect(effect_id, speed=speed, intensity=intensity)
  533. controller.set_palette(palette_id)
  534. # Set colors if provided
  535. color1 = effect_settings.get("color1")
  536. if color1:
  537. # Convert hex to RGB
  538. r1 = int(color1[1:3], 16)
  539. g1 = int(color1[3:5], 16)
  540. b1 = int(color1[5:7], 16)
  541. color2 = effect_settings.get("color2", "#000000")
  542. r2 = int(color2[1:3], 16)
  543. g2 = int(color2[3:5], 16)
  544. b2 = int(color2[5:7], 16)
  545. color3 = effect_settings.get("color3", "#0000ff")
  546. r3 = int(color3[1:3], 16)
  547. g3 = int(color3[3:5], 16)
  548. b3 = int(color3[5:7], 16)
  549. controller.set_colors(
  550. color1=(r1, g1, b1),
  551. color2=(r2, g2, b2),
  552. color3=(r3, g3, b3)
  553. )
  554. return True
  555. # Default: do nothing (keep current LED state)
  556. return True
  557. except Exception as e:
  558. logger.error(f"Error setting playing effect: {e}")
  559. return False