Current Behavior (Code Analysis)
After reviewing custom_components/beatify/services/lights.py and game/state.py, the party light system works as follows:
| Event |
What happens |
| Phase: LOBBY |
Static purple [147, 112, 219] at brightness 102 |
| Phase: PLAYING |
Static blue [0, 100, 255] at brightness 153 — sits there doing nothing |
| Phase: REVEAL |
Static warm white (3000K) at brightness 204 |
| Exact match only |
Single flash("gold") call |
| Streak / Correct guess / Countdown |
No light reaction at all |
| Game End |
Rainbow cycle (~5 s) via celebrate() |
Problems
PLAYING phase is completely static — the light turns blue and stays blue for the entire song. No blinking, no pulse, no animation.
flash() is only called for exact matches (state.py line 1590). Regular correct guesses, streaks, and the final countdown produce zero light feedback.
- No WLED support — WLED users can't use their presets/effects at all. The service calls
light.turn_on with rgb_color, which overrides any WLED effect and locks it into a static color.
celebrate() is the only dynamic effect and it's only at the very end.
Proposed Changes
1. Beat-Flash Loop during PLAYING phase
Add a background task that periodically flashes the light during song playback, cancellable when the phase changes.
In lights.py:
async def start_beat_loop(self, bpm: int = 120) -> None:
"""Start a background beat-flash loop during PLAYING phase."""
self._beat_task = asyncio.create_task(self._beat_loop(bpm))
async def stop_beat_loop(self) -> None:
"""Cancel the beat-flash loop."""
if hasattr(self, "_beat_task") and self._beat_task:
self._beat_task.cancel()
self._beat_task = None
async def _beat_loop(self, bpm: int) -> None:
interval = 60.0 / bpm
colors = [[0, 100, 255], [0, 180, 255], [0, 60, 200]] # blue variants
i = 0
try:
while True:
await self._apply(
self._entity_ids,
{"rgb_color": colors[i % len(colors)], "brightness": 200},
transition=0.1,
)
i += 1
await asyncio.sleep(interval)
except asyncio.CancelledError:
pass
In state.py — extend _lights_set_phase():
async def _lights_set_phase(self, phase: GamePhase) -> None:
if self._party_lights:
await self._party_lights.set_phase(phase)
if phase == GamePhase.PLAYING:
await self._party_lights.start_beat_loop()
else:
await self._party_lights.stop_beat_loop()
2. More flash triggers
Currently flash() is only called on exact match. Add reactions to more events:
In state.py:
# After correct guess (any):
await self._lights_flash("green")
# After wrong guess:
await self._lights_flash("red")
# On streak milestone (3x, 5x, ...):
await self._lights_flash("orange")
# Final countdown (last 5 seconds):
await self._lights_strobe(count=5, interval=0.5) # new method
New strobe() method in lights.py:
async def strobe(self, count: int = 5, interval: float = 0.4) -> None:
"""Rapid on/off strobe for countdown tension."""
for _ in range(count):
if not self._active:
break
await self._apply(self._entity_ids, {"rgb_color": [255, 0, 0], "brightness": 255}, transition=0.05)
await asyncio.sleep(interval / 2)
await self._apply(self._entity_ids, {"brightness": 10}, transition=0.05)
await asyncio.sleep(interval / 2)
# Restore phase color
if self._current_phase:
await self.set_phase(self._current_phase)
3. WLED Scene/Preset Support
WLED exposes presets via the wled HA integration (wled.preset service). When a WLED entity is detected, Beatify should switch scenes instead of setting static colors.
Detection — in _get_capability() (already exists):
def _get_capability(self, entity_id: str) -> str:
state = self._hass.states.get(entity_id)
if not state:
return "onoff"
# NEW: detect WLED by integration domain
if state.attributes.get("integration") == "wled":
return "wled"
# ... existing logic unchanged
New WLED config in Admin UI (stored in config entry):
WLED_PRESET_DEFAULTS: dict[str, int] = {
"LOBBY": 1,
"PLAYING": 2,
"REVEAL_CORRECT": 3,
"REVEAL_WRONG": 4,
"STREAK": 5,
"COUNTDOWN": 6,
"END": 7,
}
New _apply_wled() method:
async def _apply_wled(self, entity_id: str, preset_id: int) -> None:
"""Activate a WLED preset by ID."""
try:
await self._hass.services.async_call(
"wled",
"preset",
{"entity_id": entity_id, "preset": preset_id},
blocking=False,
)
except Exception:
_LOGGER.warning("Failed to set WLED preset %d on %s", preset_id, entity_id)
Modify _apply() to route WLED entities:
async def _apply(self, entity_ids, service_data, transition=1.0):
for entity_id in entity_ids:
cap = self._get_capability(entity_id)
if cap == "wled" and "wled_preset" in service_data:
await self._apply_wled(entity_id, service_data["wled_preset"])
continue
# ... existing rgb/ct/dim/onoff logic unchanged
4. Admin UI: Light Mode Selector
Add a new setting in the game setup panel:
Light Mode:
○ Static — current behavior, single color per phase
○ Dynamic — beat loop + event flashes (new)
● WLED — uses WLED presets (new)
[WLED Preset IDs — only shown in WLED mode]
Lobby: [ 1 ]
Playing (song): [ 2 ]
Reveal correct: [ 3 ]
Reveal wrong: [ 4 ]
Streak: [ 5 ]
Countdown: [ 6 ]
Game end: [ 7 ]
[ Preview ]
Summary of Changes Required
| File |
Change |
services/lights.py |
Add start_beat_loop(), stop_beat_loop(), _beat_loop(), strobe(), _apply_wled(), update _get_capability() and _apply() |
game/state.py |
Call start_beat_loop() on PLAYING phase, add flash calls for correct/wrong/streak/countdown events |
| Admin UI (JS/HTML) |
Add Light Mode selector + WLED preset ID inputs |
| Config entry |
Persist light_mode and wled_presets dict |
test_lights.py |
Add tests for new methods |
Why This Matters
The existing celebrate() rainbow at the end proves the infrastructure for dynamic effects already exists. The beat loop and WLED support are natural extensions of the same pattern. WLED is one of the most popular LED solutions in the HA community — and right now using it with Beatify kills all WLED effects and replaces them with a static blue.
References
Current Behavior (Code Analysis)
After reviewing
custom_components/beatify/services/lights.pyandgame/state.py, the party light system works as follows:[147, 112, 219]at brightness 102[0, 100, 255]at brightness 153 — sits there doing nothingflash("gold")callcelebrate()Problems
PLAYINGphase is completely static — the light turns blue and stays blue for the entire song. No blinking, no pulse, no animation.flash()is only called for exact matches (state.pyline 1590). Regular correct guesses, streaks, and the final countdown produce zero light feedback.light.turn_onwithrgb_color, which overrides any WLED effect and locks it into a static color.celebrate()is the only dynamic effect and it's only at the very end.Proposed Changes
1. Beat-Flash Loop during PLAYING phase
Add a background task that periodically flashes the light during song playback, cancellable when the phase changes.
In
lights.py:In
state.py— extend_lights_set_phase():2. More flash triggers
Currently
flash()is only called on exact match. Add reactions to more events:In
state.py:New
strobe()method inlights.py:3. WLED Scene/Preset Support
WLED exposes presets via the
wledHA integration (wled.presetservice). When a WLED entity is detected, Beatify should switch scenes instead of setting static colors.Detection — in
_get_capability()(already exists):New WLED config in Admin UI (stored in config entry):
New
_apply_wled()method:Modify
_apply()to route WLED entities:4. Admin UI: Light Mode Selector
Add a new setting in the game setup panel:
Summary of Changes Required
services/lights.pystart_beat_loop(),stop_beat_loop(),_beat_loop(),strobe(),_apply_wled(), update_get_capability()and_apply()game/state.pystart_beat_loop()on PLAYING phase, add flash calls for correct/wrong/streak/countdown eventslight_modeandwled_presetsdicttest_lights.pyWhy This Matters
The existing
celebrate()rainbow at the end proves the infrastructure for dynamic effects already exists. The beat loop and WLED support are natural extensions of the same pattern. WLED is one of the most popular LED solutions in the HA community — and right now using it with Beatify kills all WLED effects and replaces them with a static blue.References
celebrate()implementation inservices/lights.pyas pattern referencegame/state.pylines 1585–1591 (current flash trigger, exact match only)