Add and remove plants (i.e. devices) dynamically in fyta (#129221)

This commit is contained in:
dontinelli 2024-10-26 15:35:43 +02:00 committed by GitHub
parent 3b458738e0
commit 788232ca35
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 136 additions and 10 deletions

View File

@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Callable
from datetime import datetime, timedelta from datetime import datetime, timedelta
import logging import logging
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
@ -18,9 +19,10 @@ from fyta_cli.fyta_models import Plant
from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.const import CONF_ACCESS_TOKEN
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
import homeassistant.helpers.device_registry as dr
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import CONF_EXPIRATION from .const import CONF_EXPIRATION, DOMAIN
if TYPE_CHECKING: if TYPE_CHECKING:
from . import FytaConfigEntry from . import FytaConfigEntry
@ -42,6 +44,8 @@ class FytaCoordinator(DataUpdateCoordinator[dict[int, Plant]]):
update_interval=timedelta(minutes=4), update_interval=timedelta(minutes=4),
) )
self.fyta = fyta self.fyta = fyta
self._plants_last_update: set[int] = set()
self.new_device_callbacks: list[Callable[[int], None]] = []
async def _async_update_data( async def _async_update_data(
self, self,
@ -55,9 +59,62 @@ class FytaCoordinator(DataUpdateCoordinator[dict[int, Plant]]):
await self.renew_authentication() await self.renew_authentication()
try: try:
return await self.fyta.update_all_plants() data = await self.fyta.update_all_plants()
except (FytaConnectionError, FytaPlantError) as err: except (FytaConnectionError, FytaPlantError) as err:
raise UpdateFailed(err) from err raise UpdateFailed(err) from err
_LOGGER.debug("Data successfully updated")
# data must be assigned before _async_add_remove_devices, as it is uses to set-up possible new devices
self.data = data
self._async_add_remove_devices()
return data
def _async_add_remove_devices(self) -> None:
"""Add new devices, remove non-existing devices."""
if not self._plants_last_update:
self._plants_last_update = set(self.fyta.plant_list.keys())
if (
current_plants := set(self.fyta.plant_list.keys())
) == self._plants_last_update:
return
_LOGGER.debug(
"Check for new and removed plant(s): old plants: %s; new plants: %s",
", ".join(map(str, self._plants_last_update)),
", ".join(map(str, current_plants)),
)
# remove old plants
if removed_plants := self._plants_last_update - current_plants:
_LOGGER.debug("Removed plant(s): %s", ", ".join(map(str, removed_plants)))
device_registry = dr.async_get(self.hass)
for plant_id in removed_plants:
if device := device_registry.async_get_device(
identifiers={
(
DOMAIN,
f"{self.config_entry.entry_id}-{plant_id}",
)
}
):
device_registry.async_update_device(
device_id=device.id,
remove_config_entry_id=self.config_entry.entry_id,
)
_LOGGER.debug("Device removed from device registry: %s", device.id)
# add new devices
if new_plants := current_plants - self._plants_last_update:
_LOGGER.debug("New plant(s) found: %s", ", ".join(map(str, new_plants)))
for plant_id in new_plants:
for callback in self.new_device_callbacks:
callback(plant_id)
_LOGGER.debug("Device added: %s", plant_id)
self._plants_last_update = current_plants
async def renew_authentication(self) -> bool: async def renew_authentication(self) -> bool:
"""Renew access token for FYTA API.""" """Renew access token for FYTA API."""

View File

@ -150,6 +150,15 @@ async def async_setup_entry(
async_add_entities(plant_entities) async_add_entities(plant_entities)
def _async_add_new_device(plant_id: int) -> None:
async_add_entities(
FytaPlantSensor(coordinator, entry, sensor, plant_id)
for sensor in SENSORS
if sensor.key in dir(coordinator.data.get(plant_id))
)
coordinator.new_device_callbacks.append(_async_add_new_device)
class FytaPlantSensor(FytaPlantEntity, SensorEntity): class FytaPlantSensor(FytaPlantEntity, SensorEntity):
"""Represents a Fyta sensor.""" """Represents a Fyta sensor."""

View File

@ -2,7 +2,7 @@
from collections.abc import Generator from collections.abc import Generator
from datetime import UTC, datetime from datetime import UTC, datetime
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, MagicMock, patch
from fyta_cli.fyta_models import Credentials, Plant from fyta_cli.fyta_models import Credentials, Plant
import pytest import pytest
@ -46,6 +46,7 @@ def mock_fyta_connector():
tzinfo=UTC tzinfo=UTC
) )
mock_fyta_connector.client = AsyncMock(autospec=True) mock_fyta_connector.client = AsyncMock(autospec=True)
mock_fyta_connector.data = MagicMock()
mock_fyta_connector.update_all_plants.return_value = plants mock_fyta_connector.update_all_plants.return_value = plants
mock_fyta_connector.plant_list = { mock_fyta_connector.plant_list = {
0: "Gummibaum", 0: "Gummibaum",

View File

@ -9,7 +9,7 @@
"moisture_status": 3, "moisture_status": 3,
"sensor_available": true, "sensor_available": true,
"sw_version": "1.0", "sw_version": "1.0",
"status": 3, "status": 1,
"online": true, "online": true,
"ph": null, "ph": null,
"plant_id": 0, "plant_id": 0,

View File

@ -9,7 +9,7 @@
"moisture_status": 3, "moisture_status": 3,
"sensor_available": true, "sensor_available": true,
"sw_version": "1.0", "sw_version": "1.0",
"status": 3, "status": 1,
"online": true, "online": true,
"ph": 7, "ph": 7,
"plant_id": 0, "plant_id": 0,

View File

@ -0,0 +1,23 @@
{
"battery_level": 80,
"battery_status": true,
"last_updated": "2023-01-02 10:10:00",
"light": 2,
"light_status": 3,
"nickname": "Tomatenpflanze",
"moisture": 61,
"moisture_status": 3,
"sensor_available": true,
"sw_version": "1.0",
"status": 1,
"online": true,
"ph": 7,
"plant_id": 0,
"plant_origin_path": "",
"plant_thumb_path": "",
"salinity": 1,
"salinity_status": 4,
"scientific_name": "Solanum lycopersicum",
"temperature": 25.2,
"temperature_status": 3
}

View File

@ -42,7 +42,7 @@
'salinity_status': 4, 'salinity_status': 4,
'scientific_name': 'Ficus elastica', 'scientific_name': 'Ficus elastica',
'sensor_available': True, 'sensor_available': True,
'status': 3, 'status': 1,
'sw_version': '1.0', 'sw_version': '1.0',
'temperature': 25.2, 'temperature': 25.2,
'temperature_status': 3, 'temperature_status': 3,
@ -65,7 +65,7 @@
'salinity_status': 4, 'salinity_status': 4,
'scientific_name': 'Theobroma cacao', 'scientific_name': 'Theobroma cacao',
'sensor_available': True, 'sensor_available': True,
'status': 3, 'status': 1,
'sw_version': '1.0', 'sw_version': '1.0',
'temperature': 25.2, 'temperature': 25.2,
'temperature_status': 3, 'temperature_status': 3,

View File

@ -386,7 +386,7 @@
'last_changed': <ANY>, 'last_changed': <ANY>,
'last_reported': <ANY>, 'last_reported': <ANY>,
'last_updated': <ANY>, 'last_updated': <ANY>,
'state': 'no_sensor', 'state': 'doing_great',
}) })
# --- # ---
# name: test_all_entities[sensor.gummibaum_salinity-entry] # name: test_all_entities[sensor.gummibaum_salinity-entry]
@ -1052,7 +1052,7 @@
'last_changed': <ANY>, 'last_changed': <ANY>,
'last_reported': <ANY>, 'last_reported': <ANY>,
'last_updated': <ANY>, 'last_updated': <ANY>,
'state': 'no_sensor', 'state': 'doing_great',
}) })
# --- # ---
# name: test_all_entities[sensor.kakaobaum_salinity-entry] # name: test_all_entities[sensor.kakaobaum_salinity-entry]

View File

@ -5,16 +5,23 @@ from unittest.mock import AsyncMock
from freezegun.api import FrozenDateTimeFactory from freezegun.api import FrozenDateTimeFactory
from fyta_cli.fyta_exceptions import FytaConnectionError, FytaPlantError from fyta_cli.fyta_exceptions import FytaConnectionError, FytaPlantError
from fyta_cli.fyta_models import Plant
import pytest import pytest
from syrupy import SnapshotAssertion from syrupy import SnapshotAssertion
from homeassistant.components.fyta.const import DOMAIN as FYTA_DOMAIN
from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.const import STATE_UNAVAILABLE, Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er from homeassistant.helpers import entity_registry as er
from . import setup_platform from . import setup_platform
from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform from tests.common import (
MockConfigEntry,
async_fire_time_changed,
load_json_object_fixture,
snapshot_platform,
)
async def test_all_entities( async def test_all_entities(
@ -54,3 +61,32 @@ async def test_connection_error(
await hass.async_block_till_done() await hass.async_block_till_done()
assert hass.states.get("sensor.gummibaum_plant_state").state == STATE_UNAVAILABLE assert hass.states.get("sensor.gummibaum_plant_state").state == STATE_UNAVAILABLE
async def test_add_remove_entities(
hass: HomeAssistant,
mock_fyta_connector: AsyncMock,
mock_config_entry: MockConfigEntry,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test if entities are added and old are removed."""
await setup_platform(hass, mock_config_entry, [Platform.SENSOR])
assert hass.states.get("sensor.gummibaum_plant_state").state == "doing_great"
plants: dict[int, Plant] = {
0: Plant.from_dict(load_json_object_fixture("plant_status1.json", FYTA_DOMAIN)),
2: Plant.from_dict(load_json_object_fixture("plant_status3.json", FYTA_DOMAIN)),
}
mock_fyta_connector.update_all_plants.return_value = plants
mock_fyta_connector.plant_list = {
0: "Kautschukbaum",
2: "Tomatenpflanze",
}
freezer.tick(delta=timedelta(minutes=10))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert hass.states.get("sensor.kakaobaum_plant_state") is None
assert hass.states.get("sensor.tomatenpflanze_plant_state").state == "doing_great"