1
0

dw_led_controller.py 24 KB

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