Add binary sensor platform to LetPot integration (#138554)

This commit is contained in:
Joris Pelgröm 2025-02-14 20:21:30 +01:00 committed by GitHub
parent 2bfe96dded
commit c090fbfbad
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 596 additions and 22 deletions

View File

@ -22,7 +22,12 @@ from .const import (
) )
from .coordinator import LetPotConfigEntry, LetPotDeviceCoordinator from .coordinator import LetPotConfigEntry, LetPotDeviceCoordinator
PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.SWITCH, Platform.TIME] PLATFORMS: list[Platform] = [
Platform.BINARY_SENSOR,
Platform.SENSOR,
Platform.SWITCH,
Platform.TIME,
]
async def async_setup_entry(hass: HomeAssistant, entry: LetPotConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: LetPotConfigEntry) -> bool:

View File

@ -0,0 +1,122 @@
"""Support for LetPot binary sensor entities."""
from collections.abc import Callable
from dataclasses import dataclass
from letpot.models import DeviceFeature, LetPotDeviceStatus
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
BinarySensorEntityDescription,
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import LetPotConfigEntry, LetPotDeviceCoordinator
from .entity import LetPotEntity, LetPotEntityDescription
# Coordinator is used to centralize the data updates
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class LetPotBinarySensorEntityDescription(
LetPotEntityDescription, BinarySensorEntityDescription
):
"""Describes a LetPot binary sensor entity."""
is_on_fn: Callable[[LetPotDeviceStatus], bool]
BINARY_SENSORS: tuple[LetPotBinarySensorEntityDescription, ...] = (
LetPotBinarySensorEntityDescription(
key="low_nutrients",
translation_key="low_nutrients",
is_on_fn=lambda status: bool(status.errors.low_nutrients),
entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC,
device_class=BinarySensorDeviceClass.PROBLEM,
supported_fn=(
lambda coordinator: coordinator.data.errors.low_nutrients is not None
),
),
LetPotBinarySensorEntityDescription(
key="low_water",
translation_key="low_water",
is_on_fn=lambda status: bool(status.errors.low_water),
entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC,
device_class=BinarySensorDeviceClass.PROBLEM,
supported_fn=lambda coordinator: coordinator.data.errors.low_water is not None,
),
LetPotBinarySensorEntityDescription(
key="pump",
translation_key="pump",
is_on_fn=lambda status: status.pump_status == 1,
device_class=BinarySensorDeviceClass.RUNNING,
supported_fn=(
lambda coordinator: DeviceFeature.PUMP_STATUS
in coordinator.device_client.device_features
),
),
LetPotBinarySensorEntityDescription(
key="pump_error",
translation_key="pump_error",
is_on_fn=lambda status: bool(status.errors.pump_malfunction),
entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC,
device_class=BinarySensorDeviceClass.PROBLEM,
supported_fn=(
lambda coordinator: coordinator.data.errors.pump_malfunction is not None
),
),
LetPotBinarySensorEntityDescription(
key="refill_error",
translation_key="refill_error",
is_on_fn=lambda status: bool(status.errors.refill_error),
entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC,
device_class=BinarySensorDeviceClass.PROBLEM,
supported_fn=(
lambda coordinator: coordinator.data.errors.refill_error is not None
),
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: LetPotConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up LetPot binary sensor entities based on a config entry and device status/features."""
coordinators = entry.runtime_data
async_add_entities(
LetPotBinarySensorEntity(coordinator, description)
for description in BINARY_SENSORS
for coordinator in coordinators
if description.supported_fn(coordinator)
)
class LetPotBinarySensorEntity(LetPotEntity, BinarySensorEntity):
"""Defines a LetPot binary sensor entity."""
entity_description: LetPotBinarySensorEntityDescription
def __init__(
self,
coordinator: LetPotDeviceCoordinator,
description: LetPotBinarySensorEntityDescription,
) -> None:
"""Initialize LetPot binary sensor entity."""
super().__init__(coordinator)
self.entity_description = description
self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{coordinator.device.serial_number}_{description.key}"
@property
def is_on(self) -> bool:
"""Return if the binary sensor is on."""
return self.entity_description.is_on_fn(self.coordinator.data)

View File

@ -1,5 +1,25 @@
{ {
"entity": { "entity": {
"binary_sensor": {
"low_nutrients": {
"default": "mdi:beaker-alert",
"state": {
"off": "mdi:beaker"
}
},
"low_water": {
"default": "mdi:water-percent-alert",
"state": {
"off": "mdi:water-percent"
}
},
"pump": {
"default": "mdi:pump",
"state": {
"off": "mdi:pump-off"
}
}
},
"sensor": { "sensor": {
"water_level": { "water_level": {
"default": "mdi:water-percent" "default": "mdi:water-percent"

View File

@ -61,7 +61,7 @@ rules:
dynamic-devices: todo dynamic-devices: todo
entity-category: done entity-category: done
entity-device-class: done entity-device-class: done
entity-disabled-by-default: todo entity-disabled-by-default: done
entity-translations: done entity-translations: done
exception-translations: done exception-translations: done
icon-translations: done icon-translations: done

View File

@ -32,6 +32,23 @@
} }
}, },
"entity": { "entity": {
"binary_sensor": {
"low_nutrients": {
"name": "Low nutrients"
},
"low_water": {
"name": "Low water"
},
"pump": {
"name": "Pump"
},
"pump_error": {
"name": "Pump error"
},
"refill_error": {
"name": "Refill error"
}
},
"sensor": { "sensor": {
"water_level": { "water_level": {
"name": "Water level" "name": "Water level"

View File

@ -30,7 +30,7 @@ AUTHENTICATION = AuthenticationInfo(
email="email@example.com", email="email@example.com",
) )
STATUS = LetPotDeviceStatus( MAX_STATUS = LetPotDeviceStatus(
errors=LetPotDeviceErrors(low_water=True, low_nutrients=False, refill_error=False), errors=LetPotDeviceErrors(low_water=True, low_nutrients=False, refill_error=False),
light_brightness=500, light_brightness=500,
light_mode=1, light_mode=1,
@ -49,3 +49,19 @@ STATUS = LetPotDeviceStatus(
water_mode=1, water_mode=1,
water_level=100, water_level=100,
) )
SE_STATUS = LetPotDeviceStatus(
errors=LetPotDeviceErrors(low_water=True, pump_malfunction=True),
light_brightness=500,
light_mode=1,
light_schedule_end=datetime.time(18, 0),
light_schedule_start=datetime.time(8, 0),
online=True,
plant_days=1,
pump_mode=1,
pump_nutrient=None,
pump_status=0,
raw=[], # Not used by integration, and it requires a real device to get
system_on=True,
system_sound=False,
)

View File

@ -3,7 +3,7 @@
from collections.abc import Callable, Generator from collections.abc import Callable, Generator
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
from letpot.models import DeviceFeature, LetPotDevice from letpot.models import DeviceFeature, LetPotDevice, LetPotDeviceStatus
import pytest import pytest
from homeassistant.components.letpot.const import ( from homeassistant.components.letpot.const import (
@ -15,11 +15,42 @@ from homeassistant.components.letpot.const import (
) )
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_EMAIL from homeassistant.const import CONF_ACCESS_TOKEN, CONF_EMAIL
from . import AUTHENTICATION, STATUS from . import AUTHENTICATION, MAX_STATUS, SE_STATUS
from tests.common import MockConfigEntry from tests.common import MockConfigEntry
@pytest.fixture
def device_type() -> str:
"""Return the device type to use for mock data."""
return "LPH63"
def _mock_device_features(device_type: str) -> DeviceFeature:
"""Return mock device feature support for the given type."""
if device_type == "LPH31":
return DeviceFeature.LIGHT_BRIGHTNESS_LOW_HIGH | DeviceFeature.PUMP_STATUS
if device_type == "LPH63":
return (
DeviceFeature.LIGHT_BRIGHTNESS_LEVELS
| DeviceFeature.NUTRIENT_BUTTON
| DeviceFeature.PUMP_AUTO
| DeviceFeature.PUMP_STATUS
| DeviceFeature.TEMPERATURE
| DeviceFeature.WATER_LEVEL
)
raise ValueError(f"No mock data for device type {device_type}")
def _mock_device_status(device_type: str) -> LetPotDeviceStatus:
"""Return mock device status for the given type."""
if device_type == "LPH31":
return SE_STATUS
if device_type == "LPH63":
return MAX_STATUS
raise ValueError(f"No mock data for device type {device_type}")
@pytest.fixture @pytest.fixture
def mock_setup_entry() -> Generator[AsyncMock]: def mock_setup_entry() -> Generator[AsyncMock]:
"""Override async_setup_entry.""" """Override async_setup_entry."""
@ -30,7 +61,7 @@ def mock_setup_entry() -> Generator[AsyncMock]:
@pytest.fixture @pytest.fixture
def mock_client() -> Generator[AsyncMock]: def mock_client(device_type: str) -> Generator[AsyncMock]:
"""Mock a LetPotClient.""" """Mock a LetPotClient."""
with ( with (
patch( patch(
@ -47,9 +78,9 @@ def mock_client() -> Generator[AsyncMock]:
client.refresh_token.return_value = AUTHENTICATION client.refresh_token.return_value = AUTHENTICATION
client.get_devices.return_value = [ client.get_devices.return_value = [
LetPotDevice( LetPotDevice(
serial_number="LPH63ABCD", serial_number=f"{device_type}ABCD",
name="Garden", name="Garden",
device_type="LPH63", device_type=device_type,
is_online=True, is_online=True,
is_remote=False, is_remote=False,
) )
@ -58,23 +89,17 @@ def mock_client() -> Generator[AsyncMock]:
@pytest.fixture @pytest.fixture
def mock_device_client() -> Generator[AsyncMock]: def mock_device_client(device_type: str) -> Generator[AsyncMock]:
"""Mock a LetPotDeviceClient.""" """Mock a LetPotDeviceClient."""
with patch( with patch(
"homeassistant.components.letpot.coordinator.LetPotDeviceClient", "homeassistant.components.letpot.coordinator.LetPotDeviceClient",
autospec=True, autospec=True,
) as mock_device_client: ) as mock_device_client:
device_client = mock_device_client.return_value device_client = mock_device_client.return_value
device_client.device_features = ( device_client.device_features = _mock_device_features(device_type)
DeviceFeature.LIGHT_BRIGHTNESS_LEVELS device_client.device_model_code = device_type
| DeviceFeature.NUTRIENT_BUTTON device_client.device_model_name = f"LetPot {device_type}"
| DeviceFeature.PUMP_AUTO device_status = _mock_device_status(device_type)
| DeviceFeature.PUMP_STATUS
| DeviceFeature.TEMPERATURE
| DeviceFeature.WATER_LEVEL
)
device_client.device_model_code = "LPH63"
device_client.device_model_name = "LetPot Max"
subscribe_callbacks: list[Callable] = [] subscribe_callbacks: list[Callable] = []
@ -84,11 +109,11 @@ def mock_device_client() -> Generator[AsyncMock]:
def status_side_effect() -> None: def status_side_effect() -> None:
# Deliver a status update to any subscribers, like the real client # Deliver a status update to any subscribers, like the real client
for callback in subscribe_callbacks: for callback in subscribe_callbacks:
callback(STATUS) callback(device_status)
device_client.get_current_status.side_effect = status_side_effect device_client.get_current_status.side_effect = status_side_effect
device_client.get_current_status.return_value = STATUS device_client.get_current_status.return_value = device_status
device_client.last_status.return_value = STATUS device_client.last_status.return_value = device_status
device_client.request_status_update.side_effect = status_side_effect device_client.request_status_update.side_effect = status_side_effect
device_client.subscribe.side_effect = subscribe_side_effect device_client.subscribe.side_effect = subscribe_side_effect

View File

@ -0,0 +1,337 @@
# serializer version: 1
# name: test_all_entities[LPH31][binary_sensor.garden_low_water-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'binary_sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'binary_sensor.garden_low_water',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <BinarySensorDeviceClass.PROBLEM: 'problem'>,
'original_icon': None,
'original_name': 'Low water',
'platform': 'letpot',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'low_water',
'unique_id': 'a1b2c3d4e5f6a1b2c3d4e5f6_LPH31ABCD_low_water',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[LPH31][binary_sensor.garden_low_water-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'problem',
'friendly_name': 'Garden Low water',
}),
'context': <ANY>,
'entity_id': 'binary_sensor.garden_low_water',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'on',
})
# ---
# name: test_all_entities[LPH31][binary_sensor.garden_pump-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'binary_sensor',
'entity_category': None,
'entity_id': 'binary_sensor.garden_pump',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <BinarySensorDeviceClass.RUNNING: 'running'>,
'original_icon': None,
'original_name': 'Pump',
'platform': 'letpot',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'pump',
'unique_id': 'a1b2c3d4e5f6a1b2c3d4e5f6_LPH31ABCD_pump',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[LPH31][binary_sensor.garden_pump-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'running',
'friendly_name': 'Garden Pump',
}),
'context': <ANY>,
'entity_id': 'binary_sensor.garden_pump',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
# name: test_all_entities[LPH31][binary_sensor.garden_pump_error-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'binary_sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'binary_sensor.garden_pump_error',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <BinarySensorDeviceClass.PROBLEM: 'problem'>,
'original_icon': None,
'original_name': 'Pump error',
'platform': 'letpot',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'pump_error',
'unique_id': 'a1b2c3d4e5f6a1b2c3d4e5f6_LPH31ABCD_pump_error',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[LPH31][binary_sensor.garden_pump_error-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'problem',
'friendly_name': 'Garden Pump error',
}),
'context': <ANY>,
'entity_id': 'binary_sensor.garden_pump_error',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'on',
})
# ---
# name: test_all_entities[LPH63][binary_sensor.garden_low_nutrients-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'binary_sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'binary_sensor.garden_low_nutrients',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <BinarySensorDeviceClass.PROBLEM: 'problem'>,
'original_icon': None,
'original_name': 'Low nutrients',
'platform': 'letpot',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'low_nutrients',
'unique_id': 'a1b2c3d4e5f6a1b2c3d4e5f6_LPH63ABCD_low_nutrients',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[LPH63][binary_sensor.garden_low_nutrients-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'problem',
'friendly_name': 'Garden Low nutrients',
}),
'context': <ANY>,
'entity_id': 'binary_sensor.garden_low_nutrients',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
# name: test_all_entities[LPH63][binary_sensor.garden_low_water-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'binary_sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'binary_sensor.garden_low_water',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <BinarySensorDeviceClass.PROBLEM: 'problem'>,
'original_icon': None,
'original_name': 'Low water',
'platform': 'letpot',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'low_water',
'unique_id': 'a1b2c3d4e5f6a1b2c3d4e5f6_LPH63ABCD_low_water',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[LPH63][binary_sensor.garden_low_water-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'problem',
'friendly_name': 'Garden Low water',
}),
'context': <ANY>,
'entity_id': 'binary_sensor.garden_low_water',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'on',
})
# ---
# name: test_all_entities[LPH63][binary_sensor.garden_pump-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'binary_sensor',
'entity_category': None,
'entity_id': 'binary_sensor.garden_pump',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <BinarySensorDeviceClass.RUNNING: 'running'>,
'original_icon': None,
'original_name': 'Pump',
'platform': 'letpot',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'pump',
'unique_id': 'a1b2c3d4e5f6a1b2c3d4e5f6_LPH63ABCD_pump',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[LPH63][binary_sensor.garden_pump-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'running',
'friendly_name': 'Garden Pump',
}),
'context': <ANY>,
'entity_id': 'binary_sensor.garden_pump',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
# name: test_all_entities[LPH63][binary_sensor.garden_refill_error-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'binary_sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'binary_sensor.garden_refill_error',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <BinarySensorDeviceClass.PROBLEM: 'problem'>,
'original_icon': None,
'original_name': 'Refill error',
'platform': 'letpot',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'refill_error',
'unique_id': 'a1b2c3d4e5f6a1b2c3d4e5f6_LPH63ABCD_refill_error',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[LPH63][binary_sensor.garden_refill_error-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'problem',
'friendly_name': 'Garden Refill error',
}),
'context': <ANY>,
'entity_id': 'binary_sensor.garden_refill_error',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---

View File

@ -0,0 +1,32 @@
"""Test binary sensor entities for the LetPot integration."""
from unittest.mock import MagicMock, patch
import pytest
from syrupy import SnapshotAssertion
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from . import setup_integration
from tests.common import MockConfigEntry, snapshot_platform
@pytest.mark.parametrize("device_type", ["LPH63", "LPH31"])
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_all_entities(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
mock_client: MagicMock,
mock_device_client: MagicMock,
mock_config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
device_type: str,
) -> None:
"""Test binary sensor entities."""
with patch("homeassistant.components.letpot.PLATFORMS", [Platform.BINARY_SENSOR]):
await setup_integration(hass, mock_config_entry)
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)