Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
ee57aab
refactor: Update RGB Idle Light to Light Entitity
BambamNZ Jul 24, 2026
b9cb0b0
Update README.md
BambamNZ Jul 31, 2026
31956ab
Update light.py
BambamNZ Jul 31, 2026
9a59b92
Update light.py
BambamNZ Jul 31, 2026
4f2e3ea
style: auto-format python files with ruff
BambamNZ Jul 31, 2026
5af5f4e
build(deps): update pip requirement from >=25.2 to >=26.1.2
dependabot[bot] Jul 31, 2026
01ce82f
build(deps): bump home-assistant-intents from 2026.6.1 to 2026.6.24
dependabot[bot] Jul 31, 2026
249aeb0
build(deps): bump home-assistant/actions/hassfest
dependabot[bot] Jul 31, 2026
69ccf8b
build(deps): bump actions/setup-python from 6.2.0 to 7.0.0
dependabot[bot] Jul 31, 2026
a958be1
build(deps): bump actions/checkout from 6.0.2 to 7.0.1
dependabot[bot] Jul 31, 2026
cf448ce
Update .ruff.toml
BambamNZ Jul 31, 2026
d33d2f6
Merge pull request #1 from BambamNZ/dependabot/github_actions/actions…
BambamNZ Jul 31, 2026
6b910b6
Merge pull request #2 from BambamNZ/dependabot/github_actions/actions…
BambamNZ Jul 31, 2026
5c7a517
Merge pull request #3 from BambamNZ/dependabot/github_actions/home-as…
BambamNZ Jul 31, 2026
7b356cc
Merge pull request #5 from BambamNZ/dependabot/pip/home-assistant-int…
BambamNZ Jul 31, 2026
cfe0065
Merge pull request #6 from BambamNZ/dependabot/pip/pip-gte-26.1.2
BambamNZ Jul 31, 2026
eec63b3
Update dependabot.yml
BambamNZ Jul 31, 2026
8ad4745
build(deps): update ruff requirement from >=0.12.12 to >=0.16.0
dependabot[bot] Jul 31, 2026
c45252b
Update README.md
BambamNZ Jul 31, 2026
7bfaec8
Merge pull request #4 from BambamNZ/dependabot/pip/ruff-gte-0.16.0
BambamNZ Jul 31, 2026
dc48225
Update README.md
BambamNZ Jul 31, 2026
beb2ecc
Create icon.png
BambamNZ Aug 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,17 @@ updates:
- package-ecosystem: "devcontainers"
directory: "/"
schedule:
interval: "daily"
interval: "weekly"

- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "daily"
interval: "weekly"

- package-ecosystem: "pip"
directory: "/"
schedule:
interval: "daily"
interval: "weekly"
ignore:
# Dependabot should not update Home Assistant as that should match the homeassistant key in hacs.json
- dependency-name: "homeassistant"
- dependency-name: "homeassistant"
4 changes: 2 additions & 2 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,10 @@ jobs:
runs-on: "ubuntu-latest"
steps:
- name: Checkout the repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.13"

Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/validate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout the repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- name: Run hassfest validation
uses: home-assistant/actions/hassfest@d56d093b9ab8d2105bc0cb6ee9bcc0ef4ec8b96d # master
uses: home-assistant/actions/hassfest@ab22029681aa532bfe7de5774a9972d67bfbd2c0 # master

hacs: # https://github.com/hacs/action
name: HACS validation
Expand Down
1 change: 1 addition & 0 deletions .ruff.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ ignore = [
"EXE002", # incompatible with Windows
"COM812", # incompatible with formatter
"ISC001", # incompatible with formatter
"CPY001", # copyright comment block
]

[lint.flake8-pytest-style]
Expand Down
24 changes: 14 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,30 +1,31 @@
<h1 align="center" style="display: block; font-size: 2.5em; font-weight: bold; margin-block-start: 1em; margin-block-end: 1em;">
<a name="logo" href="https://github.com/ping-localhost/panda-status"><img align="center" src="https://i.imgur.com/bo7pxoH.png" alt="Panda Status" style="width:100%;height:100%"/></a>
<a name="logo" href="https://github.com/BambamNZ/panda-status"><img width="500" height="500" alt="Panda_Aura_A1-6" src="https://github.com/user-attachments/assets/f5f1d966-ceb6-44a7-8495-edb5fceca019" />
</a>
<br /><br /><strong>Panda Status</strong>
</h1>

_Control your BigTreeTech Panda Status via Home Assistant_

[![HACS](https://img.shields.io/badge/HACS-Custom-orange.svg?style=for-the-badge)](https://github.com/hacs/integration)
![GitHub release (latest by date)](https://img.shields.io/github/v/release/ping-localhost/panda-status?style=for-the-badge)
![GitHub Release Date](https://img.shields.io/github/release-date/ping-localhost/panda-status?style=for-the-badge)
![GitHub Release Date](https://img.shields.io/github/release-date/BambamNZ/panda-status?style=for-the-badge)

---

## Overview

**Panda Status** is a Home Assistant custom integration for monitoring and controlling your BigTreeTech Panda Status device. It connects via WebSocket, parses messages, and exposes device data and controls to Home Assistant.
**Panda Status** custom Home Assistant integration for monitoring and controlling your BigTreeTech Panda Status device. Connection via WebSocket, parses messages, and exposes device data and controls to Home Assistant.

**Tested with**: V1.0.1
**Tested with**: V1.0.0 Panda Aura A1 / A1 Mini

---

## Project Status

<table>
<tr>
<td><a href="https://github.com/ping-localhost/panda-status/actions/workflows/lint.yml" alt="lint"><img src="https://github.com/ping-localhost/panda-status/actions/workflows/lint.yml/badge.svg" alt="lint build"/></a></td>
<td><a href="https://github.com/ping-localhost/panda-status/actions/workflows/validate.yml" alt="MS Build"><img src="https://github.com/ping-localhost/panda-status/actions/workflows/validate.yml/badge.svg" alt="MS Build"/></a></td>
<td><a href="https://github.com/BambamNZ/panda-status/actions/workflows/lint.yml" alt="lint"><img src="https://github.com/BambamNZ/panda-status/actions/workflows/lint.yml/badge.svg" alt="lint build"/></a></td>
<td><a href="https://github.com/BambamNZ/panda-status/actions/workflows/validate.yml" alt="MS Build"><img src="https://github.com/BambamNZ/panda-status/actions/workflows/validate.yml/badge.svg" alt="MS Build"/></a></td>
</tr>
</table>

Expand All @@ -47,8 +48,12 @@ _Control your BigTreeTech Panda Status via Home Assistant_
### Switches

- **WiFi AP** - Allows you to enable/disable the AP.
- **RGB Idle Light** - Allows you enable/disable the idle light
- It just sets the brightness to 0% or 100%.

### Light

- **RGB Idle Light** - Allows control the idle light
- **Changes from original integration:**
- Idle light as an entity in HA, with expected light controls

### Select Entities

Expand All @@ -62,7 +67,6 @@ _Control your BigTreeTech Panda Status via Home Assistant_
2. [Add this repo to your HACS custom repositories](https://hacs.xyz/docs/faq/custom_repositories).
3. Search for `Panda Status` and install.
4. Restart Home Assistant.
5. Set up via the configuration flow (YAML is not supported).

## Configuration

Expand All @@ -75,7 +79,7 @@ After installation, add the integration via Home Assistant UI:

## Support & Issues

For issues or feature requests, open an [issue on GitHub](https://github.com/ping-localhost/panda-status/issues).
For issues or feature requests, open an [issue on GitHub](https://github.com/BambamNZ/panda-status/issues).

## License

Expand Down
8 changes: 8 additions & 0 deletions custom_components/panda_status/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

from __future__ import annotations

from datetime import timedelta
from typing import TYPE_CHECKING

from homeassistant.const import CONF_URL, Platform
Expand All @@ -25,6 +26,7 @@
Platform.SENSOR,
Platform.SELECT,
Platform.SWITCH,
Platform.LIGHT,
]


Expand All @@ -38,6 +40,12 @@ async def async_setup_entry(
hass=hass,
logger=LOGGER,
name=DOMAIN,
# Real polling, previously unset (see PR history). 30s turned out
# too aggressive for the device's embedded WS stack when combined
# with the 1s-per-connection timeout, causing frequent unavailable
# flaps - 60s plus the more forgiving timeout and single retry in
# the coordinator gives it more breathing room.
update_interval=timedelta(seconds=60),
)
entry.runtime_data = PandaStatusData(
client=PandaStatusWebSocket(url=entry.data[CONF_URL], session=None),
Expand Down
Binary file added custom_components/panda_status/brand/icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
34 changes: 26 additions & 8 deletions custom_components/panda_status/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed

from .websocket import PandaStatusWebsocketCommunicationError, PandaStatusWebsocketError
from .websocket import (
PandaStatusWebsocketCommunicationError,
PandaStatusWebsocketError,
PandaStatusWebsocketTimeoutError,
)

if TYPE_CHECKING:
from .data import PandaStatusConfigEntry
Expand All @@ -20,10 +24,24 @@ class PandaStatusDataUpdateCoordinator(DataUpdateCoordinator):
config_entry: PandaStatusConfigEntry

async def _async_update_data(self) -> Any:
"""Update data via library."""
try:
return await self.config_entry.runtime_data.client.async_get_data()
except PandaStatusWebsocketCommunicationError as exception:
raise ConfigEntryNotReady(exception) from exception
except PandaStatusWebsocketError as exception:
raise UpdateFailed(exception) from exception
"""Update data via library.

A single slow/timed-out poll is retried once before being treated
as a real failure. With periodic polling now running continuously
rather than only right after user actions, occasionally catching
the device's embedded WS stack mid-task is expected background
noise, not a genuine unavailability - retrying once avoids flapping
entities to unavailable over what is usually just a slow beat.
"""
attempts = 2
for attempt in range(attempts):
try:
return await self.config_entry.runtime_data.client.async_get_data()
except PandaStatusWebsocketTimeoutError as exception:
if attempt == attempts - 1:
raise UpdateFailed(exception) from exception
except PandaStatusWebsocketCommunicationError as exception:
raise ConfigEntryNotReady(exception) from exception
except PandaStatusWebsocketError as exception:
raise UpdateFailed(exception) from exception
return None # pragma: no cover - unreachable, loop always returns or raises
179 changes: 179 additions & 0 deletions custom_components/panda_status/light.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
"""Light platform for Panda Status integration."""

from __future__ import annotations

import asyncio
import json
from typing import TYPE_CHECKING, Any, ClassVar

from custom_components.panda_status import tools
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
ColorMode,
LightEntity,
LightEntityDescription,
)
from homeassistant.core import callback

from .entity import PandaStatusEntity

if TYPE_CHECKING:
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback

from .coordinator import PandaStatusDataUpdateCoordinator
from .data import PandaStatusConfigEntry


async def async_setup_entry(
hass: HomeAssistant, # noqa: ARG001
entry: PandaStatusConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the panda_status light platform."""
coordinator = entry.runtime_data.coordinator
async_add_entities(
[
PandaStatusRGBIdleLight(
coordinator=coordinator,
entity_description=LightEntityDescription(
key="rgb_idle_light",
name="RGB Idle Light",
icon="mdi:led-strip-variant",
),
),
]
)


class PandaStatusRGBIdleLight(PandaStatusEntity, LightEntity):
"""Representation of the RGB Idle Light.

Replaces the old rgb_idle_light switch. The device exposes a real
on/off flag (led.on / settings.on) that is entirely separate from
brightness (led.brightness / settings.list2[current_mode].brightness),
so this is modelled as a brightness-only light rather than a switch
that faked "on" by slamming brightness to 100.

Confirmed via direct testing against the device: on/off must be sent
as a single atomic payload including settings.current_mode - the
device will not turn off without it.
"""

_attr_color_mode = ColorMode.BRIGHTNESS
_attr_supported_color_modes: ClassVar[set[ColorMode]] = {ColorMode.BRIGHTNESS}

def __init__(
self,
coordinator: PandaStatusDataUpdateCoordinator,
entity_description: LightEntityDescription,
) -> None:
"""
Initialize the RGB Idle Light entity.

Args:
coordinator: The data update coordinator for panda_status.
entity_description: Description of the light entity.

"""
super().__init__(coordinator, entity_description)
self.entity_description = entity_description
self._attr_is_on = self._get_on_state()
self._attr_brightness = self._get_brightness()

def _get_on_state(self) -> bool | None:
"""Get the current on/off state from the real led.on flag."""
on_state = tools.extract_value(self.coordinator.data, "led.on")
if on_state is not None:
return bool(on_state)
return None

def _get_brightness(self) -> int | None:
"""Get current brightness, converted from the device's 0-100 scale."""
device_brightness = tools.extract_value(self.coordinator.data, "led.brightness")
if device_brightness is None:
return None
return round(device_brightness / 100 * 255)

def _current_mode(self) -> int:
"""Get the currently active rgb_info_mode, defaulting to 0."""
mode = tools.extract_value(self.coordinator.data, "settings.current_mode")
return mode if mode is not None else 0

@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
self._attr_is_on = self._get_on_state()
self._attr_brightness = self._get_brightness()
self.async_write_ha_state()

async def _async_reconcile_after_delay(self) -> None:
"""Reconcile with the device's real state after it has had time to settle.

Each command opens a brand new WebSocket connection (see
websocket.py) with no guarantee the device has finished applying it
before that connection closes. Refreshing immediately after sending
a command can therefore read back stale data and stomp the
optimistic state set in async_turn_on/async_turn_off. Waiting a
moment first, and running this as a background task rather than
blocking the service call, avoids that without making the UI feel
laggy.
"""
await asyncio.sleep(1)
await self.coordinator.async_request_refresh()

async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on the RGB Idle Light, optionally setting brightness."""
settings: dict[str, Any] = {
"on": True,
"current_mode": self._current_mode(),
}
led: dict[str, Any] = {"on": True}

device_brightness: int | None = None
if ATTR_BRIGHTNESS in kwargs:
# HA brightness is 0-255, device wants a 1-100 percentage.
# Clamp to 1 rather than 0 - a 0% brightness "on" call would
# visually look off but leave the device in an on/on state
# mismatched with what the user asked for; turn_off handles
# actually turning it off.
#
# Confirmed via WS sniffing of the device's own web UI: setting
# brightness requires BOTH settings.rgb_info_brightness (as a
# string) AND led.brightness (as an int) - the device does not
# propagate the former into the latter on its own, and
# led.brightness is the field this entity reads back for
# display, so sending only the settings side left the UI
# permanently stale regardless of how long a refresh waited.
device_brightness = max(1, round(kwargs[ATTR_BRIGHTNESS] / 255 * 100))
settings["rgb_info_brightness"] = str(device_brightness)
led["brightness"] = device_brightness

payload = json.dumps({"settings": settings, "led": led})
await self.coordinator.config_entry.runtime_data.client.async_send(payload)

# Update local state optimistically - this is the value the UI
# shows immediately, ahead of the delayed reconciliation below.
self._attr_is_on = True
if device_brightness is not None:
self._attr_brightness = round(device_brightness / 100 * 255)
self.async_write_ha_state()

self.hass.async_create_task(self._async_reconcile_after_delay())

async def async_turn_off(self, **kwargs: Any) -> None: # noqa: ARG002
"""Turn off the RGB Idle Light without touching brightness."""
payload = json.dumps(
{
"settings": {"on": False, "current_mode": self._current_mode()},
"led": {"on": False},
}
)
await self.coordinator.config_entry.runtime_data.client.async_send(payload)

# See async_turn_on for why this is set optimistically rather than
# waiting solely on a refresh.
self._attr_is_on = False
self.async_write_ha_state()

self.hass.async_create_task(self._async_reconcile_after_delay())
Loading