diff --git a/homeassistant/components/fyta/coordinator.py b/homeassistant/components/fyta/coordinator.py index df607de76b0..c4aa9bfe589 100644 --- a/homeassistant/components/fyta/coordinator.py +++ b/homeassistant/components/fyta/coordinator.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Callable from datetime import datetime, timedelta import logging 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.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +import homeassistant.helpers.device_registry as dr from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import CONF_EXPIRATION +from .const import CONF_EXPIRATION, DOMAIN if TYPE_CHECKING: from . import FytaConfigEntry @@ -42,6 +44,8 @@ class FytaCoordinator(DataUpdateCoordinator[dict[int, Plant]]): update_interval=timedelta(minutes=4), ) self.fyta = fyta + self._plants_last_update: set[int] = set() + self.new_device_callbacks: list[Callable[[int], None]] = [] async def _async_update_data( self, @@ -55,9 +59,62 @@ class FytaCoordinator(DataUpdateCoordinator[dict[int, Plant]]): await self.renew_authentication() try: - return await self.fyta.update_all_plants() + data = await self.fyta.update_all_plants() except (FytaConnectionError, FytaPlantError) as 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: """Renew access token for FYTA API.""" diff --git a/homeassistant/components/fyta/sensor.py b/homeassistant/components/fyta/sensor.py index f324b9b3afe..89ee22265cf 100644 --- a/homeassistant/components/fyta/sensor.py +++ b/homeassistant/components/fyta/sensor.py @@ -150,6 +150,15 @@ async def async_setup_entry( 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): """Represents a Fyta sensor.""" diff --git a/tests/components/fyta/conftest.py b/tests/components/fyta/conftest.py index 2bcad9b3c80..299b96be959 100644 --- a/tests/components/fyta/conftest.py +++ b/tests/components/fyta/conftest.py @@ -2,7 +2,7 @@ from collections.abc import Generator 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 import pytest @@ -46,6 +46,7 @@ def mock_fyta_connector(): tzinfo=UTC ) mock_fyta_connector.client = AsyncMock(autospec=True) + mock_fyta_connector.data = MagicMock() mock_fyta_connector.update_all_plants.return_value = plants mock_fyta_connector.plant_list = { 0: "Gummibaum", diff --git a/tests/components/fyta/fixtures/plant_status1.json b/tests/components/fyta/fixtures/plant_status1.json index f2e8dc9c970..72d129492bb 100644 --- a/tests/components/fyta/fixtures/plant_status1.json +++ b/tests/components/fyta/fixtures/plant_status1.json @@ -9,7 +9,7 @@ "moisture_status": 3, "sensor_available": true, "sw_version": "1.0", - "status": 3, + "status": 1, "online": true, "ph": null, "plant_id": 0, diff --git a/tests/components/fyta/fixtures/plant_status2.json b/tests/components/fyta/fixtures/plant_status2.json index a5c2735ca7c..8ed09532567 100644 --- a/tests/components/fyta/fixtures/plant_status2.json +++ b/tests/components/fyta/fixtures/plant_status2.json @@ -9,7 +9,7 @@ "moisture_status": 3, "sensor_available": true, "sw_version": "1.0", - "status": 3, + "status": 1, "online": true, "ph": 7, "plant_id": 0, diff --git a/tests/components/fyta/fixtures/plant_status3.json b/tests/components/fyta/fixtures/plant_status3.json new file mode 100644 index 00000000000..6e32ba601ed --- /dev/null +++ b/tests/components/fyta/fixtures/plant_status3.json @@ -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 +} diff --git a/tests/components/fyta/snapshots/test_diagnostics.ambr b/tests/components/fyta/snapshots/test_diagnostics.ambr index 5c68040f541..2af616c6412 100644 --- a/tests/components/fyta/snapshots/test_diagnostics.ambr +++ b/tests/components/fyta/snapshots/test_diagnostics.ambr @@ -42,7 +42,7 @@ 'salinity_status': 4, 'scientific_name': 'Ficus elastica', 'sensor_available': True, - 'status': 3, + 'status': 1, 'sw_version': '1.0', 'temperature': 25.2, 'temperature_status': 3, @@ -65,7 +65,7 @@ 'salinity_status': 4, 'scientific_name': 'Theobroma cacao', 'sensor_available': True, - 'status': 3, + 'status': 1, 'sw_version': '1.0', 'temperature': 25.2, 'temperature_status': 3, diff --git a/tests/components/fyta/snapshots/test_sensor.ambr b/tests/components/fyta/snapshots/test_sensor.ambr index 7156163ab31..ef583dd28a6 100644 --- a/tests/components/fyta/snapshots/test_sensor.ambr +++ b/tests/components/fyta/snapshots/test_sensor.ambr @@ -386,7 +386,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'no_sensor', + 'state': 'doing_great', }) # --- # name: test_all_entities[sensor.gummibaum_salinity-entry] @@ -1052,7 +1052,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'no_sensor', + 'state': 'doing_great', }) # --- # name: test_all_entities[sensor.kakaobaum_salinity-entry] diff --git a/tests/components/fyta/test_sensor.py b/tests/components/fyta/test_sensor.py index e33c54695e5..07e3965e66f 100644 --- a/tests/components/fyta/test_sensor.py +++ b/tests/components/fyta/test_sensor.py @@ -5,16 +5,23 @@ from unittest.mock import AsyncMock from freezegun.api import FrozenDateTimeFactory from fyta_cli.fyta_exceptions import FytaConnectionError, FytaPlantError +from fyta_cli.fyta_models import Plant import pytest from syrupy import SnapshotAssertion +from homeassistant.components.fyta.const import DOMAIN as FYTA_DOMAIN from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er 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( @@ -54,3 +61,32 @@ async def test_connection_error( await hass.async_block_till_done() 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"