Skip to content

Enhancement: Dynamic Light Effects & WLED Scene Support #517

@maxlin1

Description

@maxlin1

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

  1. PLAYING phase is completely static — the light turns blue and stays blue for the entire song. No blinking, no pulse, no animation.
  2. flash() is only called for exact matches (state.py line 1590). Regular correct guesses, streaks, and the final countdown produce zero light feedback.
  3. 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.
  4. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions