Skip to content

refactor: Update RGB Idle Light to Light Entity#63

Open
BambamNZ wants to merge 1 commit into
ping-localhost:mainfrom
BambamNZ:feature/idle_light_refactor
Open

refactor: Update RGB Idle Light to Light Entity#63
BambamNZ wants to merge 1 commit into
ping-localhost:mainfrom
BambamNZ:feature/idle_light_refactor

Conversation

@BambamNZ

Copy link
Copy Markdown

Replace RGB Idle Light switch with a proper light entity (on/off + brightness)

Summary

The rgb_idle_light switch faked "on/off" by slamming brightness to 100%/0%,
which meant turning it on always destroyed any custom brightness you'd set,
and caused a double-toggle bug where the first tap just resynced state and
the second one actually flipped the light. This PR replaces it with a proper
light entity that uses the device's real on/off flag, kept entirely
separate from brightness, plus working brightness control.

Problem

# old behaviour
def _get_state_from_data(self) -> bool | None:
    ...
    return list2[1].get("brightness", 0) > 0  # "on" inferred from brightness

async def async_turn_on(self, **kwargs):
    await client.async_send('{"settings":{"rgb_info_brightness":"100"}}')

async def async_turn_off(self, **kwargs):
    await client.async_send('{"settings":{"rgb_info_brightness":"0"}}')

There's no real on/off state here at all - is_on is inferred from
brightness > 0, and turn_on/turn_off just hammer brightness to 100 or
0. Two visible symptoms:

  • Custom brightness gets wiped every time the light is turned on, since
    "on" and "brightness = 100" were treated as the same thing.
  • Double-toggle required to turn on - HA's optimistic state and the
    derived brightness > 0 boolean would drift apart, so the first tap just
    resynced them and the second one actually worked.

Root cause (found via WebSocket sniffing against the device)

The firmware exposes real, independent state for on/off and brightness that
this integration was never using:

Concept Real field(s)
On/off led.on (read) / led.on + settings.on + settings.current_mode (write, must be sent together in one message)
Brightness led.brightness (read) / led.brightness (int) + settings.rgb_info_brightness (string) (write, must be sent together)

Two non-obvious things turned up during testing directly against the device
with websocat (not just capturing the web UI, since one of its captured
keys turned out to be a red herring):

  1. On/off must be sent as a single atomic payload including
    settings.current_mode
    - the device will not turn off without it:

    {"settings":{"on":false,"current_mode":0},"led":{"on":false}}

    Sending led.on and settings.on as two separate messages (which is
    what the web UI's own network capture appeared to do) is not reliable.

  2. Brightness needs both led.brightness (int) and
    settings.rgb_info_brightness (string) together
    - the settings side
    does not propagate into led.brightness on its own, and led.brightness
    is the field that reflects the live, current state. Writing only
    settings.rgb_info_brightness (as the original switch did) changes the
    physical light but leaves the reported state permanently stale:

    {"led":{"brightness":84},"settings":{"rgb_info_brightness":"84"}}

Also discovered along the way: the coordinator had no update_interval set
at all, meaning it only ever refreshed when an entity action explicitly
triggered it - immediately after sending a command. Combined with the
device using a brand-new WebSocket connection per call (see
websocket.py), an action-triggered refresh could race the device's own
internal state update and read back stale data, with nothing to
self-correct it afterwards. Adding periodic polling to fix that exposed a
second, pre-existing issue: async_get_data/async_send used a hard
1-second timeout covering the full connect+handshake+respond cycle,
with no retry. That budget was rarely tested when connections only
happened right after a user action, but polling every 30s continuously
(~2,880 times/day) reliably caught the device's embedded WS stack on a slow
beat often enough to flap the whole integration to unavailable several
times an hour. Fixed by loosening the timeout to 5s, retrying once on a
timeout before treating it as a real failure, and backing the poll interval
off to 60s.

Changes

  • light.py (new) - rgb_idle_light is now a ColorMode.BRIGHTNESS
    light entity:
    • State reads led.on / led.brightness directly instead of inferring
      on/off from brightness.
    • turn_on/turn_off send the atomic payloads described above.
    • Local state is updated optimistically so the UI responds instantly,
      with a reconciliation refresh run as a delayed background task (not
      inline) so it doesn't race the device's own state update.
  • switch.py - PandaStatusRGBIdleSwitch removed. PandaStatusAPSwitch
    is untouched.
  • websocket.py - timeout on both async_get_data and async_send
    raised from 1s to 5s, giving the device's embedded WS stack realistic
    breathing room now that polling happens continuously rather than only
    right after user actions.
  • coordinator.py - a single timeout is now retried once before being
    raised as a real UpdateFailed, so one slow poll doesn't flap entities to
    unavailable.
  • __init__.py
    • Platform.LIGHT added to PLATFORMS.
    • Coordinator now has update_interval=timedelta(seconds=60), so state
      self-corrects periodically regardless of what individual actions do,
      without hammering the device's WS stack too aggressively.

Breaking change

switch.<device>_rgb_idle_light is removed and replaced by
light.<device>_rgb_idle_light. Any automations, scripts, or dashboards
referencing the old switch entity will need to be updated to point at the
new light entity. Given this, it's probably worth a 3.0.0 version bump
rather than a patch/minor release.

Testing

Manually tested against a live device (BTT Panda Status on a Bambu A1 mini)
over several days:

  • On/off toggling from the HA UI - no more double-toggle.
  • Brightness slider while the light is on - updates the physical light and
    the displayed value correctly.
  • Turning off and back on preserves the previously set brightness instead
    of resetting it.
  • HA restart - state comes back correctly from the first coordinator poll.
  • Toggling via automations (not just manual UI interaction) - works
    correctly.
  • Left running for several days after the timeout/polling-interval fix -
    no further unavailable flapping observed (was happening several times
    an hour before that fix).

Not yet covered: behaviour under rapid repeated brightness changes (e.g.
fast slider dragging), which may still exhibit minor last-write-wins
ordering issues given the device's one-connection-per-command design - this
appears to be a protocol-level limitation rather than something fixable
purely on the entity side, and could be revisited with command debouncing
if it turns out to matter in practice.

@BambamNZ BambamNZ changed the title refactor: Update RGB Idle Light to Light Entitity refactor: Update RGB Idle Light to Light Entity Jul 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant