From db5cea51dac79355b05bb9924b077ee19e40a2fb Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 28 Aug 2026 21:50:26 +0200 Subject: [PATCH] Only add a Lutron Caseta battery sensor where there is a battery (#180545) --- .../components/lutron_caseta/binary_sensor.py | 53 +++++++++-- .../lutron_caseta/test_binary_sensor.py | 95 ++++++++++++++++++- 2 files changed, 135 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/lutron_caseta/binary_sensor.py b/homeassistant/components/lutron_caseta/binary_sensor.py index 397f1c2023eb..c554f16e6d7b 100644 --- a/homeassistant/components/lutron_caseta/binary_sensor.py +++ b/homeassistant/components/lutron_caseta/binary_sensor.py @@ -1,18 +1,25 @@ """Support for Lutron Caseta Occupancy/Vacancy/Battery Sensors.""" +import asyncio from datetime import timedelta from typing import Any, override -from pylutron_caseta import OCCUPANCY_GROUP_OCCUPIED, BridgeResponseError +from pylutron_caseta import ( + OCCUPANCY_GROUP_OCCUPIED, + BridgeDisconnectedError, + BridgeResponseError, +) +from pylutron_caseta.smartbridge import Smartbridge from homeassistant.components.binary_sensor import ( + DOMAIN as BINARY_SENSOR_DOMAIN, BinarySensorDeviceClass, BinarySensorEntity, ) from homeassistant.components.cover import DOMAIN as COVER_DOMAIN from homeassistant.const import ATTR_SUGGESTED_AREA, EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -44,13 +51,41 @@ async def async_setup_entry( LutronOccupancySensor(hass, occupancy_group, data) for occupancy_group in occupancy_groups.values() ) - async_add_entities( - ( - LutronCasetaBatterySensor(hass, device, data) - for device in bridge.get_devices_by_domain(COVER_DOMAIN) - ), - update_before_add=True, + battery_sensors = [ + LutronCasetaBatterySensor(hass, cover, data) + for cover in bridge.get_devices_by_domain(COVER_DOMAIN) + ] + reports_battery = await asyncio.gather( + *( + _async_reports_battery(bridge, sensor.device_id) + for sensor in battery_sensors + ) ) + entity_registry = er.async_get(hass) + sensors_to_add: list[LutronCasetaBatterySensor] = [] + for sensor, has_battery in zip(battery_sensors, reports_battery, strict=True): + if has_battery: + sensors_to_add.append(sensor) + elif entity_id := entity_registry.async_get_entity_id( + BINARY_SENSOR_DOMAIN, DOMAIN, sensor.unique_id + ): + # Earlier releases created this for every cover + entity_registry.async_remove(entity_id) + + async_add_entities(sensors_to_add, update_before_add=True) + + +async def _async_reports_battery(bridge: Smartbridge, device_id: str) -> bool: + """Return whether the bridge reports a battery for the device. + + A shade wired for power answers without a battery status, and nothing in + the device data the bridge caches tells the two apart. A cover the bridge + could not answer for keeps its sensor, so nothing is removed over a hiccup. + """ + try: + return await bridge.get_battery_status(device_id) is not None + except BridgeResponseError, BridgeDisconnectedError, TimeoutError: + return True class LutronOccupancySensor(LutronCasetaEntity, BinarySensorEntity): @@ -149,7 +184,7 @@ class LutronCasetaBatterySensor(LutronCasetaEntity, BinarySensorEntity): """Fetch the latest battery status from the bridge.""" try: status = await self._smartbridge.get_battery_status(self.device_id) - except BridgeResponseError: + except BridgeResponseError, BridgeDisconnectedError, TimeoutError: self._attr_is_on = None return normalized_status = status.strip().casefold() if status else None diff --git a/tests/components/lutron_caseta/test_binary_sensor.py b/tests/components/lutron_caseta/test_binary_sensor.py index 387d20365db7..61851e75efae 100644 --- a/tests/components/lutron_caseta/test_binary_sensor.py +++ b/tests/components/lutron_caseta/test_binary_sensor.py @@ -4,9 +4,14 @@ from typing import Any from unittest.mock import AsyncMock, MagicMock from freezegun.api import FrozenDateTimeFactory -from pylutron_caseta import BridgeResponseError +from pylutron_caseta import BridgeDisconnectedError, BridgeResponseError +import pytest -from homeassistant.components.binary_sensor import BinarySensorDeviceClass +from homeassistant.components.binary_sensor import ( + DOMAIN as BINARY_SENSOR_DOMAIN, + BinarySensorDeviceClass, +) +from homeassistant.components.lutron_caseta import DOMAIN from homeassistant.components.lutron_caseta.binary_sensor import SCAN_INTERVAL from homeassistant.const import ATTR_DEVICE_CLASS, STATE_OFF, STATE_ON, STATE_UNKNOWN from homeassistant.core import HomeAssistant @@ -88,6 +93,9 @@ async def test_battery_sensor_updates_on_schedule( await async_setup_integration(hass, factory) await hass.async_block_till_done() + # Setup asks once to find out whether the shade has a battery at all + instance.get_battery_status.reset_mock() + binary_sensor_entity_id = ( "binary_sensor.basement_bedroom_basement_bedroom_left_shade_battery" ) @@ -107,7 +115,7 @@ async def test_battery_sensor_updates_on_schedule( updated_state = hass.states.get(binary_sensor_entity_id) assert updated_state is not None assert updated_state.state == STATE_ON - assert instance.get_battery_status.await_count == 2 + assert instance.get_battery_status.await_count == 1 instance.get_battery_status.assert_awaited_with("802") instance.battery_statuses["802"] = "Unknown" @@ -118,7 +126,86 @@ async def test_battery_sensor_updates_on_schedule( unknown_state = hass.states.get(binary_sensor_entity_id) assert unknown_state is not None assert unknown_state.state == STATE_UNKNOWN - assert instance.get_battery_status.await_count == 3 + assert instance.get_battery_status.await_count == 2 + + +async def test_no_battery_sensor_for_a_shade_wired_for_power( + hass: HomeAssistant, +) -> None: + """Test a shade the bridge reports no battery for gets no battery sensor.""" + instance = MockBridge() + instance.battery_statuses = {} + + def factory(*args: Any, **kwargs: Any) -> MockBridge: + """Return the mock bridge instance.""" + return instance + + await async_setup_integration(hass, factory) + await hass.async_block_till_done() + + assert ( + hass.states.get( + "binary_sensor.basement_bedroom_basement_bedroom_left_shade_battery" + ) + is None + ) + + +async def test_battery_sensor_removed_when_the_shade_has_no_battery( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, +) -> None: + """Test a battery sensor from an earlier release is removed.""" + instance = MockBridge() + instance.battery_statuses = {} + + def factory(*args: Any, **kwargs: Any) -> MockBridge: + """Return the mock bridge instance.""" + return instance + + entity_registry.async_get_or_create( + BINARY_SENSOR_DOMAIN, + DOMAIN, + "000004d2_802_battery", + suggested_object_id="basement_bedroom_basement_bedroom_left_shade_battery", + ) + + await async_setup_integration(hass, factory) + await hass.async_block_till_done() + + assert ( + entity_registry.async_get_entity_id( + BINARY_SENSOR_DOMAIN, DOMAIN, "000004d2_802_battery" + ) + is None + ) + + +@pytest.mark.parametrize( + "error", + [BridgeDisconnectedError(), TimeoutError], +) +async def test_battery_sensor_kept_when_the_bridge_cannot_answer( + hass: HomeAssistant, + error: Exception, +) -> None: + """Test a cover keeps its battery sensor when the bridge cannot be asked.""" + instance = MockBridge() + instance.get_battery_status = AsyncMock(side_effect=error) + + def factory(*args: Any, **kwargs: Any) -> MockBridge: + """Return the mock bridge instance.""" + return instance + + await async_setup_integration(hass, factory) + await hass.async_block_till_done() + + assert ( + hass.states.get( + "binary_sensor.basement_bedroom_basement_bedroom_left_shade_battery" + ) + is not None + ) async def test_battery_sensor_handles_bridge_response_error(