dw_led_controller.py 24 KB

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