Only add a Lutron Caseta battery sensor where there is a battery (#180545)

This commit is contained in:
Franck Nijhof
2026-08-28 20:09:39 +00:00
parent 1cc8783dcc
commit db5cea51da
2 changed files with 135 additions and 13 deletions
@@ -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
@@ -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(