board_led_controller.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241
  1. """
  2. Board LED controller — drives the table's own LED ring through the FluidNC
  3. firmware instead of host GPIO.
  4. The firmware owns the strip ($LED/* NVS settings, live control via /sand_led)
  5. and natively handles run/idle transitions ($LED/RunEffect / $LED/IdleEffect),
  6. so the host neither renders effects nor switches them around pattern playback.
  7. This class intentionally mirrors the DWLEDController surface the /api/dw_leds/*
  8. endpoints are duck-typed against (check_status, set_power, set_brightness,
  9. set_color(s), get_effects/get_palettes, set_effect/set_palette, set_speed,
  10. set_intensity), so the existing LED page works unchanged against the board.
  11. """
  12. import logging
  13. from typing import Dict, List, Optional, Tuple
  14. logger = logging.getLogger(__name__)
  15. # Firmware effects in the firmware's order (ids are list indices).
  16. # Keep in sync with the firmware's Leds.cpp / the mobile app's LED_EFFECTS.
  17. BOARD_EFFECTS: List[Tuple[str, str]] = [
  18. ("off", "Off"), ("static", "Static"), ("rainbow", "Rainbow"),
  19. ("breathe", "Breathe"), ("colorloop", "Color loop"), ("theater", "Theater"),
  20. ("scan", "Scan"), ("running", "Running"), ("sine", "Sine"),
  21. ("gradient", "Gradient"), ("sinelon", "Sinelon"), ("twinkle", "Twinkle"),
  22. ("sparkle", "Sparkle"), ("fire", "Fire"), ("candle", "Candle"),
  23. ("meteor", "Meteor"), ("bouncing", "Bouncing"), ("wipe", "Wipe"),
  24. ("dualscan", "Dual scan"), ("juggle", "Juggle"), ("multicomet", "Multi-comet"),
  25. ("glitter", "Glitter"), ("dissolve", "Dissolve"), ("ripple", "Ripple"),
  26. ("drip", "Drip"), ("lightning", "Lightning"), ("fireworks", "Fireworks"),
  27. ("plasma", "Plasma"), ("heartbeat", "Heartbeat"), ("strobe", "Strobe"),
  28. ("police", "Police"), ("chase", "Chase"), ("railway", "Railway"),
  29. ("pacifica", "Pacifica"), ("aurora", "Aurora"), ("pride", "Pride"),
  30. ("colorwaves", "Color waves"), ("bpm", "BPM"), ("ball", "Ball"),
  31. ]
  32. BOARD_PALETTES = ["rainbow", "ocean", "lava", "forest", "party", "cloud", "heat", "sunset"]
  33. _EFFECT_ID_BY_NAME = {name: i for i, (name, _label) in enumerate(BOARD_EFFECTS)}
  34. def effect_name_for_id(effect_id: int) -> Optional[str]:
  35. if 0 <= effect_id < len(BOARD_EFFECTS):
  36. return BOARD_EFFECTS[effect_id][0]
  37. return None
  38. def effect_id_for_name(name: str) -> int:
  39. return _EFFECT_ID_BY_NAME.get((name or "").lower(), 0)
  40. class BoardLEDController:
  41. """DWLEDController-compatible facade over the firmware's LED API."""
  42. def __init__(self):
  43. # Restore target for set_power(1) when the strip was turned off.
  44. self._last_effect = "static"
  45. # -- helpers ---------------------------------------------------------
  46. def _conn(self):
  47. from modules.core.state import state
  48. if not state.conn or not state.conn.is_connected():
  49. return None
  50. return state.conn
  51. def _led(self, **keys) -> Dict:
  52. """Send live LED control (/sand_led); works mid-pattern, NVS-persisted at idle."""
  53. conn = self._conn()
  54. if not conn:
  55. return {"connected": False, "error": "Table not connected"}
  56. try:
  57. conn.set_led(**keys)
  58. return {"connected": True, "power_on": keys.get("effect") != "off"}
  59. except Exception as e:
  60. logger.warning(f"Board LED control failed ({keys}): {e}")
  61. return {"connected": False, "error": str(e)}
  62. def _read(self) -> Optional[Dict]:
  63. """Read the board's $LED/* settings map (None when unreachable)."""
  64. conn = self._conn()
  65. if not conn:
  66. return None
  67. try:
  68. settings = conn.get_settings()
  69. return {k[4:]: v for k, v in settings.items() if k.startswith("LED/")}
  70. except Exception as e:
  71. logger.warning(f"Could not read board LED settings: {e}")
  72. return None
  73. # -- DWLEDController-compatible surface -------------------------------
  74. def check_status(self) -> Dict:
  75. led = self._read()
  76. if led is None:
  77. return {"connected": False, "error": "Table not connected"}
  78. effect = (led.get("Effect") or "off").lower()
  79. if effect != "off":
  80. self._last_effect = effect
  81. brightness_255 = int(led.get("Brightness", 128) or 0)
  82. palette = (led.get("Palette") or "rainbow").lower()
  83. return {
  84. "connected": True,
  85. "power_on": effect != "off",
  86. "brightness": round(brightness_255 * 100 / 255),
  87. "speed": int(led.get("Speed", 128) or 0),
  88. "intensity": 0, # no firmware equivalent
  89. "current_effect": effect_id_for_name(effect),
  90. "current_palette": BOARD_PALETTES.index(palette) if palette in BOARD_PALETTES else 0,
  91. "num_leds": 0, # owned by the board's config.yaml
  92. "gpio_pin": None,
  93. "colors": [
  94. f"#{led.get('Color', 'FF0000')}",
  95. f"#{led.get('Color2', '000000')}",
  96. "#000000",
  97. ],
  98. "run_effect": (led.get("RunEffect") or "none").lower(),
  99. "idle_effect": (led.get("IdleEffect") or "none").lower(),
  100. # 'ball' effect params (firmware-native; the blob that follows the ball).
  101. "ball": {
  102. "fgbright": int(led.get("BallBright", 255) or 0),
  103. "bgbright": int(led.get("BallBgBright", 255) or 0),
  104. "size": int(led.get("BallSize", 3) or 0),
  105. "bg": (led.get("BallBg") or "static").lower(),
  106. "direction": (led.get("Direction") or "cw").lower(),
  107. "align": int(led.get("Align", 0) or 0),
  108. },
  109. }
  110. def set_power(self, power_state: int) -> Dict:
  111. status = self.check_status()
  112. if not status.get("connected"):
  113. return status
  114. currently_on = status.get("power_on", False)
  115. turn_on = not currently_on if power_state == 2 else bool(power_state)
  116. if turn_on == currently_on:
  117. return status
  118. result = self._led(effect=self._last_effect if turn_on else "off")
  119. result["power_on"] = turn_on
  120. return result
  121. def set_brightness(self, value: int) -> Dict:
  122. # dw API is 0-100; the firmware wants 0-255.
  123. return self._led(brightness=max(0, min(255, round(value * 255 / 100))))
  124. def set_color(self, r: int, g: int, b: int) -> Dict:
  125. return self._led(color=f"{r:02X}{g:02X}{b:02X}")
  126. def set_colors(self, color1=None, color2=None, color3=None) -> Dict:
  127. keys = {}
  128. if color1:
  129. keys["color"] = "{:02X}{:02X}{:02X}".format(*color1)
  130. if color2:
  131. keys["color2"] = "{:02X}{:02X}{:02X}".format(*color2)
  132. # color3 has no firmware equivalent
  133. if not keys:
  134. return {"connected": True}
  135. return self._led(**keys)
  136. def get_effects(self) -> List[Tuple[int, str]]:
  137. return [(i, label) for i, (_name, label) in enumerate(BOARD_EFFECTS)]
  138. def get_palettes(self) -> List[Tuple[int, str]]:
  139. return [(i, name.capitalize()) for i, name in enumerate(BOARD_PALETTES)]
  140. def set_effect(self, effect_id: int, speed: Optional[int] = None,
  141. intensity: Optional[int] = None) -> Dict:
  142. name = effect_name_for_id(int(effect_id))
  143. if name is None:
  144. return {"connected": False, "error": f"Unknown effect id {effect_id}"}
  145. if name != "off":
  146. self._last_effect = name
  147. keys = {"effect": name}
  148. if speed is not None:
  149. keys["speed"] = max(1, min(255, int(speed)))
  150. return self._led(**keys)
  151. def set_palette(self, palette_id: int) -> Dict:
  152. if not 0 <= int(palette_id) < len(BOARD_PALETTES):
  153. return {"connected": False, "error": f"Unknown palette id {palette_id}"}
  154. return self._led(palette=BOARD_PALETTES[int(palette_id)])
  155. def set_speed(self, value: int) -> Dict:
  156. return self._led(speed=max(1, min(255, int(value))))
  157. def set_intensity(self, value: int) -> Dict:
  158. # The firmware has no intensity control; accept and ignore.
  159. return {"connected": True}
  160. # -- 'ball' tracker (firmware-native blob that follows the sand ball) -----
  161. # Live /sand_led keys, with their clamp ranges (persisted to NVS at idle).
  162. _BALL_KEYS = {
  163. "fgbright": (0, 255), # blob brightness
  164. "bgbright": (0, 255), # background brightness
  165. "size": (1, 200), # glow size in LEDs
  166. "align": (0, 359), # rotate the blob onto the ball (degrees)
  167. }
  168. def set_ball(self, **params) -> Dict:
  169. """Tune the 'ball' effect live via /sand_led (only meaningful while the
  170. 'ball' effect is active). Accepted keys: fgbright, bgbright, size, align
  171. (ints), direction ('cw'|'ccw'), bg (sub-effect name / 'static' / 'off'),
  172. color, color2 (RRGGBB hex)."""
  173. keys: dict = {}
  174. for key, (lo, hi) in self._BALL_KEYS.items():
  175. if params.get(key) is not None:
  176. keys[key] = max(lo, min(hi, int(params[key])))
  177. if params.get("direction") in ("cw", "ccw"):
  178. keys["direction"] = params["direction"]
  179. if params.get("bg"):
  180. keys["bg"] = str(params["bg"])
  181. for c in ("color", "color2"):
  182. if params.get(c):
  183. keys[c] = str(params[c]).lstrip("#").upper()
  184. if not keys:
  185. return {"connected": True}
  186. return self._led(**keys)
  187. # -- firmware-native run/idle automation -------------------------------
  188. def set_run_effect(self, effect_name: str) -> bool:
  189. """$LED/RunEffect — the board switches to it while moving ('none' disables)."""
  190. return self._set_auto_effect("LED/RunEffect", effect_name)
  191. def set_idle_effect(self, effect_name: str) -> bool:
  192. """$LED/IdleEffect — the board switches to it at idle ('none' disables)."""
  193. return self._set_auto_effect("LED/IdleEffect", effect_name)
  194. def _set_auto_effect(self, key: str, effect_name: str) -> bool:
  195. conn = self._conn()
  196. if not conn:
  197. return False
  198. try:
  199. conn.set_setting(key, effect_name or "none")
  200. return True
  201. except Exception as e:
  202. logger.warning(f"Could not set {key}={effect_name}: {e}")
  203. return False
  204. def stop(self):
  205. """Provider-switch hook; nothing to tear down (the board keeps running)."""