From 98132d1cd3b31f366aa66fb048190a1354cebd3d Mon Sep 17 00:00:00 2001 From: dontinelli <73341522+dontinelli@users.noreply.github.com> Date: Fri, 15 Mar 2024 18:13:35 +0100 Subject: [PATCH] Add Fyta integration (#110816) * Initial commit for fyta integration * Update __init__.py Delete BinarySensor for first PR * Update __init__.py Rewind wrongful deletion of comma * Delete homeassistant/components/fyta/binary_sensor.py Delete binary_sensor for first pr of integration * Update manifest.json Updated requirement to new version of fyta_cli 0.2.1, where bug in import of modules has been resolved. * Update requirements_test_all.txt adjust to updated manifest * Update requirements_all.txt adjust to updated manifest * Update test_config_flow.py * Update config_flow.py update file to correct error with _entry attribute * Fyta integration - update initial PR based on review in initial PR #110816 (#2) * adjustments to pass test for config_flow * backport of changes in intitial PR to dev * update text_config_flow * changes based on review in initial PR #110816 * Update homeassistant/components/fyta/sensor.py Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/fyta/config_flow.py Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/fyta/config_flow.py Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/fyta/sensor.py Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/fyta/sensor.py Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/fyta/coordinator.py Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/fyta/config_flow.py Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/fyta/strings.json Co-authored-by: Sid <27780930+autinerd@users.noreply.github.com> * Update homeassistant/components/fyta/strings.json Co-authored-by: Sid <27780930+autinerd@users.noreply.github.com> * Update homeassistant/components/fyta/manifest.json Co-authored-by: Sid <27780930+autinerd@users.noreply.github.com> * Adjustments based on PR-commet of Feb 19 (#3) * add test for config_flow.validate_input * update based on pr review * update based on pr review * further refinings based on PR review * Update tests/components/fyta/test_config_flow.py Co-authored-by: Joost Lekkerkerker * Update test_config_flow.py Update tests based on PR comment * Update homeassistant/components/fyta/sensor.py Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/fyta/sensor.py Co-authored-by: Joost Lekkerkerker * add handling and test for duplicate entry * Update homeassistant/components/fyta/coordinator.py Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/fyta/sensor.py Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/fyta/entity.py Co-authored-by: Joost Lekkerkerker * Update test_config_flow.py parametrize test for exceptions * Update config_flow.py Move _async_abort_entries_match, add arguments * Update coordinator.py * Update typing in coordinator.py * Update coordinator.py update typing * Update coordinator.py corrected typo * Update coordinator.py * Update entity.py * Update sensor.py * Update icons.json * Update homeassistant/components/fyta/entity.py Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/fyta/entity.py Co-authored-by: Joost Lekkerkerker * Update entity.py * Update test_config_flow.py * Update config_flow.py (change FlowResult to ConfigFlowResult) * Update config_flow.py * Update homeassistant/components/fyta/config_flow.py Co-authored-by: Robert Resch * Update homeassistant/components/fyta/config_flow.py Co-authored-by: Robert Resch * Update homeassistant/components/fyta/coordinator.py Co-authored-by: Robert Resch * Update coordinator.py * Update config_flow.py (typing FlowResult -> ConfigFlowResult) * Update config_flow.py * Aktualisieren von config_flow.py * remove coordinator entities * Update strings.json remove plant_number * Update icons.json remove plant_number * Update manifest.json Update requirement to latest fyta_cli version * Update requirements_all.txt * Update requirements_test_all.txt * Update homeassistant/components/fyta/sensor.py * Update homeassistant/components/fyta/sensor.py * Update homeassistant/components/fyta/coordinator.py Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/fyta/coordinator.py Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/fyta/coordinator.py Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/fyta/entity.py Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/fyta/strings.json Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/fyta/strings.json Co-authored-by: Joost Lekkerkerker * Update tests/components/fyta/test_config_flow.py Co-authored-by: Joost Lekkerkerker * Update tests/components/fyta/test_config_flow.py Co-authored-by: Joost Lekkerkerker * move test-helpers into conftest.py, adjust import of coordinator.py --------- Co-authored-by: Joost Lekkerkerker Co-authored-by: Sid <27780930+autinerd@users.noreply.github.com> Co-authored-by: Robert Resch --- .coveragerc | 4 + CODEOWNERS | 2 + homeassistant/components/fyta/__init__.py | 48 +++++++ homeassistant/components/fyta/config_flow.py | 64 +++++++++ homeassistant/components/fyta/const.py | 2 + homeassistant/components/fyta/coordinator.py | 55 +++++++ homeassistant/components/fyta/entity.py | 47 ++++++ homeassistant/components/fyta/icons.json | 27 ++++ homeassistant/components/fyta/manifest.json | 10 ++ homeassistant/components/fyta/sensor.py | 143 +++++++++++++++++++ homeassistant/components/fyta/strings.json | 83 +++++++++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/fyta/__init__.py | 1 + tests/components/fyta/conftest.py | 32 +++++ tests/components/fyta/test_config_flow.py | 121 ++++++++++++++++ 18 files changed, 652 insertions(+) create mode 100644 homeassistant/components/fyta/__init__.py create mode 100644 homeassistant/components/fyta/config_flow.py create mode 100644 homeassistant/components/fyta/const.py create mode 100644 homeassistant/components/fyta/coordinator.py create mode 100644 homeassistant/components/fyta/entity.py create mode 100644 homeassistant/components/fyta/icons.json create mode 100644 homeassistant/components/fyta/manifest.json create mode 100644 homeassistant/components/fyta/sensor.py create mode 100644 homeassistant/components/fyta/strings.json create mode 100644 tests/components/fyta/__init__.py create mode 100644 tests/components/fyta/conftest.py create mode 100644 tests/components/fyta/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index 792cf9ddfe0..7a66f88ed87 100644 --- a/.coveragerc +++ b/.coveragerc @@ -461,6 +461,10 @@ omit = homeassistant/components/frontier_silicon/browse_media.py homeassistant/components/frontier_silicon/media_player.py homeassistant/components/futurenow/light.py + homeassistant/components/fyta/__init__.py + homeassistant/components/fyta/coordinator.py + homeassistant/components/fyta/entity.py + homeassistant/components/fyta/sensor.py homeassistant/components/garadget/cover.py homeassistant/components/garages_amsterdam/__init__.py homeassistant/components/garages_amsterdam/binary_sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index 9cac303b92d..b81fade1402 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -453,6 +453,8 @@ build.json @home-assistant/supervisor /tests/components/frontier_silicon/ @wlcrs /homeassistant/components/fully_kiosk/ @cgarwood /tests/components/fully_kiosk/ @cgarwood +/homeassistant/components/fyta/ @dontinelli +/tests/components/fyta/ @dontinelli /homeassistant/components/garages_amsterdam/ @klaasnicolaas /tests/components/garages_amsterdam/ @klaasnicolaas /homeassistant/components/gardena_bluetooth/ @elupus diff --git a/homeassistant/components/fyta/__init__.py b/homeassistant/components/fyta/__init__.py new file mode 100644 index 00000000000..34399ce23ee --- /dev/null +++ b/homeassistant/components/fyta/__init__.py @@ -0,0 +1,48 @@ +"""Initialization of FYTA integration.""" +from __future__ import annotations + +import logging + +from fyta_cli.fyta_connector import FytaConnector + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform +from homeassistant.core import HomeAssistant + +from .const import DOMAIN +from .coordinator import FytaCoordinator + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS = [ + Platform.SENSOR, +] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up the Fyta integration.""" + + username = entry.data[CONF_USERNAME] + password = entry.data[CONF_PASSWORD] + + fyta = FytaConnector(username, password) + + coordinator = FytaCoordinator(hass, fyta) + + await coordinator.async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload Fyta entity.""" + + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/fyta/config_flow.py b/homeassistant/components/fyta/config_flow.py new file mode 100644 index 00000000000..698686a41a7 --- /dev/null +++ b/homeassistant/components/fyta/config_flow.py @@ -0,0 +1,64 @@ +"""Config flow for FYTA integration.""" +from __future__ import annotations + +import logging +from typing import Any + +from fyta_cli.fyta_connector import FytaConnector +from fyta_cli.fyta_exceptions import ( + FytaAuthentificationError, + FytaConnectionError, + FytaPasswordError, +) +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +DATA_SCHEMA = vol.Schema( + {vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str} +) + + +class FytaConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Fyta.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> config_entries.ConfigFlowResult: + """Handle the initial step.""" + + errors = {} + if user_input: + self._async_abort_entries_match({CONF_USERNAME: user_input[CONF_USERNAME]}) + + fyta = FytaConnector(user_input[CONF_USERNAME], user_input[CONF_PASSWORD]) + + try: + await fyta.login() + except FytaConnectionError: + errors["base"] = "cannot_connect" + except FytaAuthentificationError: + errors["base"] = "invalid_auth" + except FytaPasswordError: + errors["base"] = "invalid_auth" + errors[CONF_PASSWORD] = "password_error" + except Exception: # pylint: disable=broad-except + errors["base"] = "unknown" + else: + return self.async_create_entry( + title=user_input[CONF_USERNAME], data=user_input + ) + finally: + await fyta.client.close() + + return self.async_show_form( + step_id="user", data_schema=DATA_SCHEMA, errors=errors + ) diff --git a/homeassistant/components/fyta/const.py b/homeassistant/components/fyta/const.py new file mode 100644 index 00000000000..86c3121089d --- /dev/null +++ b/homeassistant/components/fyta/const.py @@ -0,0 +1,2 @@ +"""Const for fyta integration.""" +DOMAIN = "fyta" diff --git a/homeassistant/components/fyta/coordinator.py b/homeassistant/components/fyta/coordinator.py new file mode 100644 index 00000000000..c132ee75e72 --- /dev/null +++ b/homeassistant/components/fyta/coordinator.py @@ -0,0 +1,55 @@ +"""Coordinator for FYTA integration.""" + +from datetime import datetime, timedelta +import logging +from typing import Any + +from fyta_cli.fyta_connector import FytaConnector +from fyta_cli.fyta_exceptions import ( + FytaAuthentificationError, + FytaConnectionError, + FytaPasswordError, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + + +class FytaCoordinator(DataUpdateCoordinator[dict[int, dict[str, Any]]]): + """Fyta custom coordinator.""" + + config_entry: ConfigEntry + + def __init__(self, hass: HomeAssistant, fyta: FytaConnector) -> None: + """Initialize my coordinator.""" + super().__init__( + hass, + _LOGGER, + name="FYTA Coordinator", + update_interval=timedelta(seconds=60), + ) + self.fyta = fyta + + async def _async_update_data( + self, + ) -> dict[int, dict[str, Any]]: + """Fetch data from API endpoint.""" + + if self.fyta.expiration is None or self.fyta.expiration < datetime.now(): + await self.renew_authentication() + + return await self.fyta.update_all_plants() + + async def renew_authentication(self) -> None: + """Renew access token for FYTA API.""" + + try: + await self.fyta.login() + except FytaConnectionError as ex: + raise ConfigEntryNotReady from ex + except (FytaAuthentificationError, FytaPasswordError) as ex: + raise ConfigEntryError from ex diff --git a/homeassistant/components/fyta/entity.py b/homeassistant/components/fyta/entity.py new file mode 100644 index 00000000000..a0bcf0a0084 --- /dev/null +++ b/homeassistant/components/fyta/entity.py @@ -0,0 +1,47 @@ +"""Entities for FYTA integration.""" +from typing import Any + +from homeassistant.components.sensor import SensorEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import FytaCoordinator + + +class FytaPlantEntity(CoordinatorEntity[FytaCoordinator]): + """Base Fyta Plant entity.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: FytaCoordinator, + entry: ConfigEntry, + description: SensorEntityDescription, + plant_id: int, + ) -> None: + """Initialize the Fyta sensor.""" + super().__init__(coordinator) + + self.plant_id = plant_id + self._attr_unique_id = f"{entry.entry_id}-{plant_id}-{description.key}" + self._attr_device_info = DeviceInfo( + manufacturer="Fyta", + model="Plant", + identifiers={(DOMAIN, f"{entry.entry_id}-{plant_id}")}, + name=self.plant.get("name"), + sw_version=self.plant.get("sw_version"), + ) + self.entity_description = description + + @property + def plant(self) -> dict[str, Any]: + """Get plant data.""" + return self.coordinator.data[self.plant_id] + + @property + def available(self) -> bool: + """Test if entity is available.""" + return super().available and self.plant_id in self.coordinator.data diff --git a/homeassistant/components/fyta/icons.json b/homeassistant/components/fyta/icons.json new file mode 100644 index 00000000000..b96eeb15e62 --- /dev/null +++ b/homeassistant/components/fyta/icons.json @@ -0,0 +1,27 @@ +{ + "entity": { + "sensor": { + "status": { + "default": "mdi:flower" + }, + "temperature_status": { + "default": "mdi:thermometer-lines" + }, + "light_status": { + "default": "mdi:sun-clock-outline" + }, + "moisture_status": { + "default": "mdi:water-percent-alert" + }, + "salinity_status": { + "default": "mdi:sprout-outline" + }, + "light": { + "default": "mdi:weather-sunny" + }, + "salinity": { + "default": "mdi:sprout-outline" + } + } + } +} diff --git a/homeassistant/components/fyta/manifest.json b/homeassistant/components/fyta/manifest.json new file mode 100644 index 00000000000..a93a76a9e1d --- /dev/null +++ b/homeassistant/components/fyta/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "fyta", + "name": "FYTA", + "codeowners": ["@dontinelli"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/fyta", + "integration_type": "hub", + "iot_class": "cloud_polling", + "requirements": ["fyta_cli==0.3.3"] +} diff --git a/homeassistant/components/fyta/sensor.py b/homeassistant/components/fyta/sensor.py new file mode 100644 index 00000000000..ae1c1bec272 --- /dev/null +++ b/homeassistant/components/fyta/sensor.py @@ -0,0 +1,143 @@ +"""Summary data from Fyta.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime +from typing import Final + +from fyta_cli.fyta_connector import PLANT_STATUS + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import FytaCoordinator +from .entity import FytaPlantEntity + + +@dataclass(frozen=True) +class FytaSensorEntityDescription(SensorEntityDescription): + """Describes Fyta sensor entity.""" + + value_fn: Callable[[str | int | float | datetime], str | int | float | datetime] = ( + lambda value: value + ) + + +PLANT_STATUS_LIST: list[str] = ["too_low", "low", "perfect", "high", "too_high"] + +SENSORS: Final[list[FytaSensorEntityDescription]] = [ + FytaSensorEntityDescription( + key="scientific_name", + translation_key="scientific_name", + ), + FytaSensorEntityDescription( + key="status", + translation_key="plant_status", + device_class=SensorDeviceClass.ENUM, + options=PLANT_STATUS_LIST, + value_fn=lambda value: PLANT_STATUS[value], + ), + FytaSensorEntityDescription( + key="temperature_status", + translation_key="temperature_status", + device_class=SensorDeviceClass.ENUM, + options=PLANT_STATUS_LIST, + value_fn=lambda value: PLANT_STATUS[value], + ), + FytaSensorEntityDescription( + key="light_status", + translation_key="light_status", + device_class=SensorDeviceClass.ENUM, + options=PLANT_STATUS_LIST, + value_fn=lambda value: PLANT_STATUS[value], + ), + FytaSensorEntityDescription( + key="moisture_status", + translation_key="moisture_status", + device_class=SensorDeviceClass.ENUM, + options=PLANT_STATUS_LIST, + value_fn=lambda value: PLANT_STATUS[value], + ), + FytaSensorEntityDescription( + key="salinity_status", + translation_key="salinity_status", + device_class=SensorDeviceClass.ENUM, + options=PLANT_STATUS_LIST, + value_fn=lambda value: PLANT_STATUS[value], + ), + FytaSensorEntityDescription( + key="temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + FytaSensorEntityDescription( + key="light", + translation_key="light", + native_unit_of_measurement="mol/d", + state_class=SensorStateClass.MEASUREMENT, + ), + FytaSensorEntityDescription( + key="moisture", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.MOISTURE, + state_class=SensorStateClass.MEASUREMENT, + ), + FytaSensorEntityDescription( + key="salinity", + translation_key="salinity", + native_unit_of_measurement="mS/cm", + state_class=SensorStateClass.MEASUREMENT, + ), + FytaSensorEntityDescription( + key="ph", + device_class=SensorDeviceClass.PH, + state_class=SensorStateClass.MEASUREMENT, + ), + FytaSensorEntityDescription( + key="battery_level", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.BATTERY, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the FYTA sensors.""" + coordinator: FytaCoordinator = hass.data[DOMAIN][entry.entry_id] + + plant_entities = [ + FytaPlantSensor(coordinator, entry, sensor, plant_id) + for plant_id in coordinator.fyta.plant_list + for sensor in SENSORS + if sensor.key in coordinator.data[plant_id] + ] + + async_add_entities(plant_entities) + + +class FytaPlantSensor(FytaPlantEntity, SensorEntity): + """Represents a Fyta sensor.""" + + entity_description: FytaSensorEntityDescription + + @property + def native_value(self) -> str | int | float | datetime: + """Return the state for this sensor.""" + + val = self.plant[self.entity_description.key] + return self.entity_description.value_fn(val) diff --git a/homeassistant/components/fyta/strings.json b/homeassistant/components/fyta/strings.json new file mode 100644 index 00000000000..6d4fe68a86c --- /dev/null +++ b/homeassistant/components/fyta/strings.json @@ -0,0 +1,83 @@ +{ + "config": { + "step": { + "user": { + "title": "Credentials for FYTA API", + "description": "Provide username and password to connect to the FYTA server", + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "password_error": "Invalid password", + "unknown": "[%key:common::config_flow::error::unknown%]" + } + }, + "entity": { + "sensor": { + "scientific_name": { + "name": "Scientific name" + }, + "plant_status": { + "name": "Plant state", + "state": { + "too_low": "Too low", + "low": "Low", + "perfect": "Perfect", + "high": "High", + "too_high": "Too high" + } + }, + "temperature_status": { + "name": "Temperature state", + "state": { + "too_low": "[%key:component::fyta::entity::sensor::plant_status::state::too_low%]", + "low": "[%key:component::fyta::entity::sensor::plant_status::state::low%]", + "perfect": "[%key:component::fyta::entity::sensor::plant_status::state::perfect%]", + "high": "[%key:component::fyta::entity::sensor::plant_status::state::high%]", + "too_high": "[%key:component::fyta::entity::sensor::plant_status::state::too_high%]" + } + }, + "light_status": { + "name": "Light state", + "state": { + "too_low": "[%key:component::fyta::entity::sensor::plant_status::state::too_low%]", + "low": "[%key:component::fyta::entity::sensor::plant_status::state::low%]", + "perfect": "[%key:component::fyta::entity::sensor::plant_status::state::perfect%]", + "high": "[%key:component::fyta::entity::sensor::plant_status::state::high%]", + "too_high": "[%key:component::fyta::entity::sensor::plant_status::state::too_high%]" + } + }, + "moisture_status": { + "name": "Moisture state", + "state": { + "too_low": "[%key:component::fyta::entity::sensor::plant_status::state::too_low%]", + "low": "[%key:component::fyta::entity::sensor::plant_status::state::low%]", + "perfect": "[%key:component::fyta::entity::sensor::plant_status::state::perfect%]", + "high": "[%key:component::fyta::entity::sensor::plant_status::state::high%]", + "too_high": "[%key:component::fyta::entity::sensor::plant_status::state::too_high%]" + } + }, + "salinity_status": { + "name": "Salinity state", + "state": { + "too_low": "[%key:component::fyta::entity::sensor::plant_status::state::too_low%]", + "low": "[%key:component::fyta::entity::sensor::plant_status::state::low%]", + "perfect": "[%key:component::fyta::entity::sensor::plant_status::state::perfect%]", + "high": "[%key:component::fyta::entity::sensor::plant_status::state::high%]", + "too_high": "[%key:component::fyta::entity::sensor::plant_status::state::too_high%]" + } + }, + "light": { + "name": "Light" + }, + "salinity": { + "name": "Salinity" + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index a049e2ca108..957a7696055 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -179,6 +179,7 @@ FLOWS = { "fronius", "frontier_silicon", "fully_kiosk", + "fyta", "garages_amsterdam", "gardena_bluetooth", "gdacs", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 1de015ac780..2c82810e072 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -2049,6 +2049,12 @@ "config_flow": false, "iot_class": "local_polling" }, + "fyta": { + "name": "FYTA", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "garadget": { "name": "Garadget", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index 4ee5604ad47..7948214c64c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -895,6 +895,9 @@ freesms==0.2.0 # homeassistant.components.fritzbox_callmonitor fritzconnection[qr]==1.13.2 +# homeassistant.components.fyta +fyta_cli==0.3.3 + # homeassistant.components.google_translate gTTS==2.2.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 18baa476413..1b75ae9aaa2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -727,6 +727,9 @@ freebox-api==1.1.0 # homeassistant.components.fritzbox_callmonitor fritzconnection[qr]==1.13.2 +# homeassistant.components.fyta +fyta_cli==0.3.3 + # homeassistant.components.google_translate gTTS==2.2.4 diff --git a/tests/components/fyta/__init__.py b/tests/components/fyta/__init__.py new file mode 100644 index 00000000000..cdc2cf63b0d --- /dev/null +++ b/tests/components/fyta/__init__.py @@ -0,0 +1 @@ +"""Tests for the Fyta integration.""" diff --git a/tests/components/fyta/conftest.py b/tests/components/fyta/conftest.py new file mode 100644 index 00000000000..40ab4925a47 --- /dev/null +++ b/tests/components/fyta/conftest.py @@ -0,0 +1,32 @@ +"""Test helpers.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + +from .test_config_flow import ACCESS_TOKEN, EXPIRATION + + +@pytest.fixture +def mock_fyta(): + """Build a fixture for the Fyta API that connects successfully and returns one device.""" + + mock_fyta_api = AsyncMock() + with patch( + "homeassistant.components.fyta.config_flow.FytaConnector", + return_value=mock_fyta_api, + ) as mock_fyta_api: + mock_fyta_api.return_value.login.return_value = { + "access_token": ACCESS_TOKEN, + "expiration": EXPIRATION, + } + yield mock_fyta_api + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.fyta.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/fyta/test_config_flow.py b/tests/components/fyta/test_config_flow.py new file mode 100644 index 00000000000..1fdb9b6109c --- /dev/null +++ b/tests/components/fyta/test_config_flow.py @@ -0,0 +1,121 @@ +"""Test the fyta config flow.""" +from datetime import datetime +from unittest.mock import AsyncMock + +from fyta_cli.fyta_exceptions import ( + FytaAuthentificationError, + FytaConnectionError, + FytaPasswordError, +) +import pytest + +from homeassistant import config_entries, data_entry_flow +from homeassistant.components.fyta.const import DOMAIN +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +USERNAME = "fyta_user" +PASSWORD = "fyta_pass" +ACCESS_TOKEN = "123xyz" +EXPIRATION = datetime.now() + + +async def test_user_flow( + hass: HomeAssistant, mock_fyta: AsyncMock, mock_setup_entry +) -> None: + """Test we get the form.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["errors"] == {} + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD} + ) + await hass.async_block_till_done() + + assert result2["type"] == "create_entry" + assert result2["title"] == USERNAME + assert result2["data"] == {CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD} + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (FytaConnectionError, {"base": "cannot_connect"}), + (FytaAuthentificationError, {"base": "invalid_auth"}), + (FytaPasswordError, {"base": "invalid_auth", CONF_PASSWORD: "password_error"}), + (Exception, {"base": "unknown"}), + ], +) +async def test_form_exceptions( + hass: HomeAssistant, + exception: Exception, + error: dict[str, str], + mock_fyta: AsyncMock, + mock_setup_entry, +) -> None: + """Test we can handle Form exceptions.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + mock_fyta.return_value.login.side_effect = exception + + # tests with connection error + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD} + ) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == error + + mock_fyta.return_value.login.side_effect = None + + # tests with all information provided + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD} + ) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["title"] == USERNAME + assert result["data"][CONF_USERNAME] == USERNAME + assert result["data"][CONF_PASSWORD] == PASSWORD + + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_duplicate_entry(hass: HomeAssistant, mock_fyta: AsyncMock) -> None: + """Test duplicate setup handling.""" + entry = MockConfigEntry( + domain=DOMAIN, + title=USERNAME, + data={CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}, + ) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.FlowResultType.ABORT + assert result["reason"] == "already_configured"