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
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:

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": {
"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": {
"water_level": {
"default": "mdi:water-percent"

View File

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

View File

@ -32,6 +32,23 @@
}
},
"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": {
"water_level": {
"name": "Water level"

View File

@ -30,7 +30,7 @@ AUTHENTICATION = AuthenticationInfo(
email="email@example.com",
)
STATUS = LetPotDeviceStatus(
MAX_STATUS = LetPotDeviceStatus(
errors=LetPotDeviceErrors(low_water=True, low_nutrients=False, refill_error=False),
light_brightness=500,
light_mode=1,
@ -49,3 +49,19 @@ STATUS = LetPotDeviceStatus(
water_mode=1,
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 unittest.mock import AsyncMock, patch
from letpot.models import DeviceFeature, LetPotDevice
from letpot.models import DeviceFeature, LetPotDevice, LetPotDeviceStatus
import pytest
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 . import AUTHENTICATION, STATUS
from . import AUTHENTICATION, MAX_STATUS, SE_STATUS
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
def mock_setup_entry() -> Generator[AsyncMock]:
"""Override async_setup_entry."""
@ -30,7 +61,7 @@ def mock_setup_entry() -> Generator[AsyncMock]:
@pytest.fixture
def mock_client() -> Generator[AsyncMock]:
def mock_client(device_type: str) -> Generator[AsyncMock]:
"""Mock a LetPotClient."""
with (
patch(
@ -47,9 +78,9 @@ def mock_client() -> Generator[AsyncMock]:
client.refresh_token.return_value = AUTHENTICATION
client.get_devices.return_value = [
LetPotDevice(
serial_number="LPH63ABCD",
serial_number=f"{device_type}ABCD",
name="Garden",
device_type="LPH63",
device_type=device_type,
is_online=True,
is_remote=False,
)
@ -58,23 +89,17 @@ def mock_client() -> Generator[AsyncMock]:
@pytest.fixture
def mock_device_client() -> Generator[AsyncMock]:
def mock_device_client(device_type: str) -> Generator[AsyncMock]:
"""Mock a LetPotDeviceClient."""
with patch(
"homeassistant.components.letpot.coordinator.LetPotDeviceClient",
autospec=True,
) as mock_device_client:
device_client = mock_device_client.return_value
device_client.device_features = (
DeviceFeature.LIGHT_BRIGHTNESS_LEVELS
| DeviceFeature.NUTRIENT_BUTTON
| DeviceFeature.PUMP_AUTO
| DeviceFeature.PUMP_STATUS
| DeviceFeature.TEMPERATURE
| DeviceFeature.WATER_LEVEL
)
device_client.device_model_code = "LPH63"
device_client.device_model_name = "LetPot Max"
device_client.device_features = _mock_device_features(device_type)
device_client.device_model_code = device_type
device_client.device_model_name = f"LetPot {device_type}"
device_status = _mock_device_status(device_type)
subscribe_callbacks: list[Callable] = []
@ -84,11 +109,11 @@ def mock_device_client() -> Generator[AsyncMock]:
def status_side_effect() -> None:
# Deliver a status update to any subscribers, like the real client
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.return_value = STATUS
device_client.last_status.return_value = STATUS
device_client.get_current_status.return_value = device_status
device_client.last_status.return_value = device_status
device_client.request_status_update.side_effect = status_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)