diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index 4f1901a..79292a1 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -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"
\ No newline at end of file
+ - dependency-name: "homeassistant"
diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
index d99913a..9bf9a8e 100644
--- a/.github/workflows/lint.yml
+++ b/.github/workflows/lint.yml
@@ -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"
diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml
index 521ff73..dc6376d 100644
--- a/.github/workflows/validate.yml
+++ b/.github/workflows/validate.yml
@@ -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
diff --git a/.ruff.toml b/.ruff.toml
index 73a4fed..574f232 100644
--- a/.ruff.toml
+++ b/.ruff.toml
@@ -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]
diff --git a/README.md b/README.md
index c5f35e7..8e4a8e6 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,6 @@
-
+
+
Panda Status
@@ -7,15 +8,15 @@ _Control your BigTreeTech Panda Status via Home Assistant_
[](https://github.com/hacs/integration)

-
+
---
## 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
---
@@ -23,8 +24,8 @@ _Control your BigTreeTech Panda Status via Home Assistant_
@@ -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
@@ -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
@@ -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
diff --git a/custom_components/panda_status/__init__.py b/custom_components/panda_status/__init__.py
index 5a3c8f6..e5ef83e 100644
--- a/custom_components/panda_status/__init__.py
+++ b/custom_components/panda_status/__init__.py
@@ -7,6 +7,7 @@
from __future__ import annotations
+from datetime import timedelta
from typing import TYPE_CHECKING
from homeassistant.const import CONF_URL, Platform
@@ -25,6 +26,7 @@
Platform.SENSOR,
Platform.SELECT,
Platform.SWITCH,
+ Platform.LIGHT,
]
@@ -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),
diff --git a/custom_components/panda_status/brand/icon.png b/custom_components/panda_status/brand/icon.png
new file mode 100644
index 0000000..d043432
Binary files /dev/null and b/custom_components/panda_status/brand/icon.png differ
diff --git a/custom_components/panda_status/coordinator.py b/custom_components/panda_status/coordinator.py
index 6528f4b..fe42bb6 100644
--- a/custom_components/panda_status/coordinator.py
+++ b/custom_components/panda_status/coordinator.py
@@ -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
@@ -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
diff --git a/custom_components/panda_status/light.py b/custom_components/panda_status/light.py
new file mode 100644
index 0000000..44d6873
--- /dev/null
+++ b/custom_components/panda_status/light.py
@@ -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())
diff --git a/custom_components/panda_status/switch.py b/custom_components/panda_status/switch.py
index f2e535e..c8e1211 100644
--- a/custom_components/panda_status/switch.py
+++ b/custom_components/panda_status/switch.py
@@ -45,16 +45,6 @@ async def async_setup_entry(
device_class=SwitchDeviceClass.SWITCH,
),
),
- PandaStatusRGBIdleSwitch(
- coordinator=coordinator,
- entity_description=SwitchEntityDescription(
- key="rgb_idle_light",
- name="RGB Idle Light",
- icon="mdi:lightbulb",
- entity_category=EntityCategory.CONFIG,
- device_class=SwitchDeviceClass.SWITCH,
- ),
- ),
]
)
@@ -104,54 +94,3 @@ async def async_turn_off(self, **kwargs: Any) -> None: # noqa: ARG002
'{"ap":{"on":0}}'
)
await self.coordinator.async_request_refresh()
-
-
-class PandaStatusRGBIdleSwitch(PandaStatusEntity, SwitchEntity):
- """Representation of the RGB Idle Light Switch."""
-
- def __init__(
- self,
- coordinator: PandaStatusDataUpdateCoordinator,
- entity_description: SwitchEntityDescription,
- ) -> None:
- """
- Initialize the RGB Idle Light Switch entity.
-
- Args:
- coordinator: The data update coordinator for panda_status.
- entity_description: Description of the switch entity.
-
- """
- super().__init__(coordinator, entity_description)
- self.entity_description = entity_description
- self._attr_is_on = self._get_state_from_data()
-
- def _get_state_from_data(self) -> bool | None:
- """Get the current state from coordinator data."""
- last_msg = self.coordinator.data
- if last_msg and "settings" in last_msg:
- list2 = last_msg["settings"].get("list2", [])
- if len(list2) > 1:
- return list2[1].get("brightness", 0) > 0
- return False
- return None
-
- @callback
- def _handle_coordinator_update(self) -> None:
- """Handle updated data from the coordinator."""
- self._attr_is_on = self._get_state_from_data()
- self.async_write_ha_state()
-
- async def async_turn_on(self, **kwargs: Any) -> None: # noqa: ARG002
- """Turn on the RGB Idle Light."""
- await self.coordinator.config_entry.runtime_data.client.async_send(
- '{"settings":{"rgb_info_brightness":"100"}}'
- )
- await self.coordinator.async_request_refresh()
-
- async def async_turn_off(self, **kwargs: Any) -> None: # noqa: ARG002
- """Turn off the RGB Idle Light."""
- await self.coordinator.config_entry.runtime_data.client.async_send(
- '{"settings":{"rgb_info_brightness":"0"}}'
- )
- await self.coordinator.async_request_refresh()
diff --git a/custom_components/panda_status/websocket.py b/custom_components/panda_status/websocket.py
index 292183e..88ca025 100644
--- a/custom_components/panda_status/websocket.py
+++ b/custom_components/panda_status/websocket.py
@@ -62,7 +62,12 @@ async def async_get_data(self) -> dict:
"""
try:
- async with asyncio.timeout(1):
+ # 1 second was workable when this connection only ever happened
+ # right after a user action; now that the coordinator polls
+ # periodically, that budget is regularly too tight for the
+ # device's own connect+respond time and was causing frequent
+ # false-negative "unavailable" flaps.
+ async with asyncio.timeout(5):
async with self._session as websocket:
data = json.loads(await websocket.recv())
except TimeoutError as e:
@@ -93,7 +98,7 @@ async def async_send(self, payload: str) -> None:
"""
try:
_LOGGER.debug("Sending payload: %s", payload)
- async with asyncio.timeout(1):
+ async with asyncio.timeout(5):
async with self._session as websocket:
await websocket.send(payload)
_LOGGER.debug("Payload sent: %s", payload)
diff --git a/pyproject.toml b/pyproject.toml
index 5d6df45..c1706af 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -7,14 +7,14 @@ dependencies = [
"ffmpeg",
"ha-ffmpeg",
"hassil",
- "home-assistant-intents==2026.6.1",
+ "home-assistant-intents==2026.6.24",
"homeassistant==2026.2.3",
"mutagen",
"numpy",
- "pip>=25.2",
+ "pip>=26.1.2",
"pymicro-vad",
"pyspeex-noise",
"PyTurboJPEG",
- "ruff>=0.12.12",
+ "ruff>=0.16.0",
"websockets",
]