Skip to content

Commit 788232c

Browse files
authored
Add and remove plants (i.e. devices) dynamically in fyta (#129221)
1 parent 3b45873 commit 788232c

File tree

9 files changed

+136
-10
lines changed

9 files changed

+136
-10
lines changed

homeassistant/components/fyta/coordinator.py

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from __future__ import annotations
44

5+
from collections.abc import Callable
56
from datetime import datetime, timedelta
67
import logging
78
from typing import TYPE_CHECKING
@@ -18,9 +19,10 @@
1819
from homeassistant.const import CONF_ACCESS_TOKEN
1920
from homeassistant.core import HomeAssistant
2021
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
22+
import homeassistant.helpers.device_registry as dr
2123
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
2224

23-
from .const import CONF_EXPIRATION
25+
from .const import CONF_EXPIRATION, DOMAIN
2426

2527
if TYPE_CHECKING:
2628
from . import FytaConfigEntry
@@ -42,6 +44,8 @@ def __init__(self, hass: HomeAssistant, fyta: FytaConnector) -> None:
4244
update_interval=timedelta(minutes=4),
4345
)
4446
self.fyta = fyta
47+
self._plants_last_update: set[int] = set()
48+
self.new_device_callbacks: list[Callable[[int], None]] = []
4549

4650
async def _async_update_data(
4751
self,
@@ -55,9 +59,62 @@ async def _async_update_data(
5559
await self.renew_authentication()
5660

5761
try:
58-
return await self.fyta.update_all_plants()
62+
data = await self.fyta.update_all_plants()
5963
except (FytaConnectionError, FytaPlantError) as err:
6064
raise UpdateFailed(err) from err
65+
_LOGGER.debug("Data successfully updated")
66+
67+
# data must be assigned before _async_add_remove_devices, as it is uses to set-up possible new devices
68+
self.data = data
69+
self._async_add_remove_devices()
70+
71+
return data
72+
73+
def _async_add_remove_devices(self) -> None:
74+
"""Add new devices, remove non-existing devices."""
75+
if not self._plants_last_update:
76+
self._plants_last_update = set(self.fyta.plant_list.keys())
77+
78+
if (
79+
current_plants := set(self.fyta.plant_list.keys())
80+
) == self._plants_last_update:
81+
return
82+
83+
_LOGGER.debug(
84+
"Check for new and removed plant(s): old plants: %s; new plants: %s",
85+
", ".join(map(str, self._plants_last_update)),
86+
", ".join(map(str, current_plants)),
87+
)
88+
89+
# remove old plants
90+
if removed_plants := self._plants_last_update - current_plants:
91+
_LOGGER.debug("Removed plant(s): %s", ", ".join(map(str, removed_plants)))
92+
93+
device_registry = dr.async_get(self.hass)
94+
for plant_id in removed_plants:
95+
if device := device_registry.async_get_device(
96+
identifiers={
97+
(
98+
DOMAIN,
99+
f"{self.config_entry.entry_id}-{plant_id}",
100+
)
101+
}
102+
):
103+
device_registry.async_update_device(
104+
device_id=device.id,
105+
remove_config_entry_id=self.config_entry.entry_id,
106+
)
107+
_LOGGER.debug("Device removed from device registry: %s", device.id)
108+
109+
# add new devices
110+
if new_plants := current_plants - self._plants_last_update:
111+
_LOGGER.debug("New plant(s) found: %s", ", ".join(map(str, new_plants)))
112+
for plant_id in new_plants:
113+
for callback in self.new_device_callbacks:
114+
callback(plant_id)
115+
_LOGGER.debug("Device added: %s", plant_id)
116+
117+
self._plants_last_update = current_plants
61118

62119
async def renew_authentication(self) -> bool:
63120
"""Renew access token for FYTA API."""

homeassistant/components/fyta/sensor.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,15 @@ async def async_setup_entry(
150150

151151
async_add_entities(plant_entities)
152152

153+
def _async_add_new_device(plant_id: int) -> None:
154+
async_add_entities(
155+
FytaPlantSensor(coordinator, entry, sensor, plant_id)
156+
for sensor in SENSORS
157+
if sensor.key in dir(coordinator.data.get(plant_id))
158+
)
159+
160+
coordinator.new_device_callbacks.append(_async_add_new_device)
161+
153162

154163
class FytaPlantSensor(FytaPlantEntity, SensorEntity):
155164
"""Represents a Fyta sensor."""

tests/components/fyta/conftest.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from collections.abc import Generator
44
from datetime import UTC, datetime
5-
from unittest.mock import AsyncMock, patch
5+
from unittest.mock import AsyncMock, MagicMock, patch
66

77
from fyta_cli.fyta_models import Credentials, Plant
88
import pytest
@@ -46,6 +46,7 @@ def mock_fyta_connector():
4646
tzinfo=UTC
4747
)
4848
mock_fyta_connector.client = AsyncMock(autospec=True)
49+
mock_fyta_connector.data = MagicMock()
4950
mock_fyta_connector.update_all_plants.return_value = plants
5051
mock_fyta_connector.plant_list = {
5152
0: "Gummibaum",

tests/components/fyta/fixtures/plant_status1.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
"moisture_status": 3,
1010
"sensor_available": true,
1111
"sw_version": "1.0",
12-
"status": 3,
12+
"status": 1,
1313
"online": true,
1414
"ph": null,
1515
"plant_id": 0,

tests/components/fyta/fixtures/plant_status2.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
"moisture_status": 3,
1010
"sensor_available": true,
1111
"sw_version": "1.0",
12-
"status": 3,
12+
"status": 1,
1313
"online": true,
1414
"ph": 7,
1515
"plant_id": 0,
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
{
2+
"battery_level": 80,
3+
"battery_status": true,
4+
"last_updated": "2023-01-02 10:10:00",
5+
"light": 2,
6+
"light_status": 3,
7+
"nickname": "Tomatenpflanze",
8+
"moisture": 61,
9+
"moisture_status": 3,
10+
"sensor_available": true,
11+
"sw_version": "1.0",
12+
"status": 1,
13+
"online": true,
14+
"ph": 7,
15+
"plant_id": 0,
16+
"plant_origin_path": "",
17+
"plant_thumb_path": "",
18+
"salinity": 1,
19+
"salinity_status": 4,
20+
"scientific_name": "Solanum lycopersicum",
21+
"temperature": 25.2,
22+
"temperature_status": 3
23+
}

tests/components/fyta/snapshots/test_diagnostics.ambr

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@
4242
'salinity_status': 4,
4343
'scientific_name': 'Ficus elastica',
4444
'sensor_available': True,
45-
'status': 3,
45+
'status': 1,
4646
'sw_version': '1.0',
4747
'temperature': 25.2,
4848
'temperature_status': 3,
@@ -65,7 +65,7 @@
6565
'salinity_status': 4,
6666
'scientific_name': 'Theobroma cacao',
6767
'sensor_available': True,
68-
'status': 3,
68+
'status': 1,
6969
'sw_version': '1.0',
7070
'temperature': 25.2,
7171
'temperature_status': 3,

tests/components/fyta/snapshots/test_sensor.ambr

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -386,7 +386,7 @@
386386
'last_changed': <ANY>,
387387
'last_reported': <ANY>,
388388
'last_updated': <ANY>,
389-
'state': 'no_sensor',
389+
'state': 'doing_great',
390390
})
391391
# ---
392392
# name: test_all_entities[sensor.gummibaum_salinity-entry]
@@ -1052,7 +1052,7 @@
10521052
'last_changed': <ANY>,
10531053
'last_reported': <ANY>,
10541054
'last_updated': <ANY>,
1055-
'state': 'no_sensor',
1055+
'state': 'doing_great',
10561056
})
10571057
# ---
10581058
# name: test_all_entities[sensor.kakaobaum_salinity-entry]

tests/components/fyta/test_sensor.py

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,23 @@
55

66
from freezegun.api import FrozenDateTimeFactory
77
from fyta_cli.fyta_exceptions import FytaConnectionError, FytaPlantError
8+
from fyta_cli.fyta_models import Plant
89
import pytest
910
from syrupy import SnapshotAssertion
1011

12+
from homeassistant.components.fyta.const import DOMAIN as FYTA_DOMAIN
1113
from homeassistant.const import STATE_UNAVAILABLE, Platform
1214
from homeassistant.core import HomeAssistant
1315
from homeassistant.helpers import entity_registry as er
1416

1517
from . import setup_platform
1618

17-
from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
19+
from tests.common import (
20+
MockConfigEntry,
21+
async_fire_time_changed,
22+
load_json_object_fixture,
23+
snapshot_platform,
24+
)
1825

1926

2027
async def test_all_entities(
@@ -54,3 +61,32 @@ async def test_connection_error(
5461
await hass.async_block_till_done()
5562

5663
assert hass.states.get("sensor.gummibaum_plant_state").state == STATE_UNAVAILABLE
64+
65+
66+
async def test_add_remove_entities(
67+
hass: HomeAssistant,
68+
mock_fyta_connector: AsyncMock,
69+
mock_config_entry: MockConfigEntry,
70+
freezer: FrozenDateTimeFactory,
71+
) -> None:
72+
"""Test if entities are added and old are removed."""
73+
await setup_platform(hass, mock_config_entry, [Platform.SENSOR])
74+
75+
assert hass.states.get("sensor.gummibaum_plant_state").state == "doing_great"
76+
77+
plants: dict[int, Plant] = {
78+
0: Plant.from_dict(load_json_object_fixture("plant_status1.json", FYTA_DOMAIN)),
79+
2: Plant.from_dict(load_json_object_fixture("plant_status3.json", FYTA_DOMAIN)),
80+
}
81+
mock_fyta_connector.update_all_plants.return_value = plants
82+
mock_fyta_connector.plant_list = {
83+
0: "Kautschukbaum",
84+
2: "Tomatenpflanze",
85+
}
86+
87+
freezer.tick(delta=timedelta(minutes=10))
88+
async_fire_time_changed(hass)
89+
await hass.async_block_till_done()
90+
91+
assert hass.states.get("sensor.kakaobaum_plant_state") is None
92+
assert hass.states.get("sensor.tomatenpflanze_plant_state").state == "doing_great"

0 commit comments

Comments
 (0)