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 +Panda_Aura_A1-6 +

Panda Status

@@ -7,15 +8,15 @@ _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 --- @@ -23,8 +24,8 @@ _Control your BigTreeTech Panda Status via Home Assistant_ - - + +
lint buildMS Buildlint buildMS Build
@@ -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", ]