From e03ccb5ab624327031011adf3c82575ee597eced Mon Sep 17 00:00:00 2001 From: Isak Nyberg <36712644+IsakNyberg@users.noreply.github.com> Date: Fri, 24 Nov 2023 10:40:59 +0100 Subject: [PATCH] Add Mypermobil integration (#95613) Co-authored-by: Franck Nijhof Co-authored-by: Robert Resch --- .coveragerc | 3 + CODEOWNERS | 2 + homeassistant/components/permobil/__init__.py | 63 ++++ .../components/permobil/config_flow.py | 173 +++++++++++ homeassistant/components/permobil/const.py | 11 + .../components/permobil/coordinator.py | 57 ++++ .../components/permobil/manifest.json | 9 + homeassistant/components/permobil/sensor.py | 222 ++++++++++++++ .../components/permobil/strings.json | 70 +++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/permobil/__init__.py | 1 + tests/components/permobil/conftest.py | 27 ++ tests/components/permobil/const.py | 5 + tests/components/permobil/test_config_flow.py | 288 ++++++++++++++++++ 17 files changed, 944 insertions(+) create mode 100644 homeassistant/components/permobil/__init__.py create mode 100644 homeassistant/components/permobil/config_flow.py create mode 100644 homeassistant/components/permobil/const.py create mode 100644 homeassistant/components/permobil/coordinator.py create mode 100644 homeassistant/components/permobil/manifest.json create mode 100644 homeassistant/components/permobil/sensor.py create mode 100644 homeassistant/components/permobil/strings.json create mode 100644 tests/components/permobil/__init__.py create mode 100644 tests/components/permobil/conftest.py create mode 100644 tests/components/permobil/const.py create mode 100644 tests/components/permobil/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index 684498f33a9..f28ef24e4b2 100644 --- a/.coveragerc +++ b/.coveragerc @@ -931,6 +931,9 @@ omit = homeassistant/components/panasonic_viera/media_player.py homeassistant/components/pandora/media_player.py homeassistant/components/pencom/switch.py + homeassistant/components/permobil/__init__.py + homeassistant/components/permobil/coordinator.py + homeassistant/components/permobil/sensor.py homeassistant/components/philips_js/__init__.py homeassistant/components/philips_js/light.py homeassistant/components/philips_js/media_player.py diff --git a/CODEOWNERS b/CODEOWNERS index 342f0d35a9b..d7c8eca064c 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -944,6 +944,8 @@ build.json @home-assistant/supervisor /tests/components/peco/ @IceBotYT /homeassistant/components/pegel_online/ @mib1185 /tests/components/pegel_online/ @mib1185 +/homeassistant/components/permobil/ @IsakNyberg +/tests/components/permobil/ @IsakNyberg /homeassistant/components/persistent_notification/ @home-assistant/core /tests/components/persistent_notification/ @home-assistant/core /homeassistant/components/philips_js/ @elupus diff --git a/homeassistant/components/permobil/__init__.py b/homeassistant/components/permobil/__init__.py new file mode 100644 index 00000000000..2f3c4c04c50 --- /dev/null +++ b/homeassistant/components/permobil/__init__.py @@ -0,0 +1,63 @@ +"""The MyPermobil integration.""" +from __future__ import annotations + +import logging + +from mypermobil import MyPermobil, MyPermobilClientException + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONF_CODE, + CONF_EMAIL, + CONF_REGION, + CONF_TOKEN, + CONF_TTL, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed + +from .const import APPLICATION, DOMAIN +from .coordinator import MyPermobilCoordinator + +PLATFORMS: list[Platform] = [Platform.SENSOR] + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up MyPermobil from a config entry.""" + + # create the API object from the config and save it in hass + session = hass.helpers.aiohttp_client.async_get_clientsession() + p_api = MyPermobil( + application=APPLICATION, + session=session, + email=entry.data[CONF_EMAIL], + region=entry.data[CONF_REGION], + code=entry.data[CONF_CODE], + token=entry.data[CONF_TOKEN], + expiration_date=entry.data[CONF_TTL], + ) + try: + p_api.self_authenticate() + except MyPermobilClientException as err: + _LOGGER.error("Error authenticating %s", err) + raise ConfigEntryAuthFailed(f"Config error for {p_api.email}") from err + + # create the coordinator with the API object + coordinator = MyPermobilCoordinator(hass, p_api) + 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 a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/permobil/config_flow.py b/homeassistant/components/permobil/config_flow.py new file mode 100644 index 00000000000..644ea29d8a3 --- /dev/null +++ b/homeassistant/components/permobil/config_flow.py @@ -0,0 +1,173 @@ +"""Config flow for MyPermobil integration.""" +from __future__ import annotations + +from collections.abc import Mapping +import logging +from typing import Any + +from mypermobil import MyPermobil, MyPermobilAPIException, MyPermobilClientException +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_CODE, CONF_EMAIL, CONF_REGION, CONF_TOKEN, CONF_TTL +from homeassistant.core import HomeAssistant, async_get_hass +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import selector +from homeassistant.helpers.aiohttp_client import async_get_clientsession +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.selector import ( + TextSelector, + TextSelectorConfig, + TextSelectorType, +) + +from .const import APPLICATION, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +GET_EMAIL_SCHEMA = vol.Schema( + { + vol.Required(CONF_EMAIL): TextSelector( + TextSelectorConfig(type=TextSelectorType.EMAIL) + ), + } +) + +GET_TOKEN_SCHEMA = vol.Schema({vol.Required(CONF_CODE): cv.string}) + + +class PermobilConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Permobil config flow.""" + + VERSION = 1 + region_names: dict[str, str] = {} + data: dict[str, str] = {} + + def __init__(self) -> None: + """Initialize flow.""" + hass: HomeAssistant = async_get_hass() + session = async_get_clientsession(hass) + self.p_api = MyPermobil(APPLICATION, session=session) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Invoke when a user initiates a flow via the user interface.""" + errors: dict[str, str] = {} + + if user_input: + try: + self.p_api.set_email(user_input[CONF_EMAIL]) + except MyPermobilClientException: + _LOGGER.exception("Error validating email") + errors["base"] = "invalid_email" + + self.data.update(user_input) + + await self.async_set_unique_id(self.data[CONF_EMAIL]) + self._abort_if_unique_id_configured() + + if errors or not user_input: + return self.async_show_form( + step_id="user", data_schema=GET_EMAIL_SCHEMA, errors=errors + ) + return await self.async_step_region() + + async def async_step_region( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Invoke when a user initiates a flow via the user interface.""" + errors: dict[str, str] = {} + if not user_input: + # fetch the list of regions names and urls from the api + # for the user to select from. + try: + self.region_names = await self.p_api.request_region_names() + _LOGGER.debug( + "region names %s", + ",".join(list(self.region_names.keys())), + ) + except MyPermobilAPIException: + _LOGGER.exception("Error requesting regions") + errors["base"] = "region_fetch_error" + + else: + region_url = self.region_names[user_input[CONF_REGION]] + + self.data[CONF_REGION] = region_url + self.p_api.set_region(region_url) + _LOGGER.debug("region %s", self.p_api.region) + try: + # tell backend to send code to the users email + await self.p_api.request_application_code() + except MyPermobilAPIException: + _LOGGER.exception("Error requesting code") + errors["base"] = "code_request_error" + + if errors or not user_input: + # the error could either be that the fetch region did not pass + # or that the request application code failed + schema = vol.Schema( + { + vol.Required(CONF_REGION): selector.SelectSelector( + selector.SelectSelectorConfig( + options=list(self.region_names.keys()), + mode=selector.SelectSelectorMode.DROPDOWN, + ) + ), + } + ) + return self.async_show_form( + step_id="region", data_schema=schema, errors=errors + ) + + return await self.async_step_email_code() + + async def async_step_email_code( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Second step in config flow to enter the email code.""" + errors: dict[str, str] = {} + + if user_input: + try: + self.p_api.set_code(user_input[CONF_CODE]) + self.data.update(user_input) + token, ttl = await self.p_api.request_application_token() + self.data[CONF_TOKEN] = token + self.data[CONF_TTL] = ttl + except (MyPermobilAPIException, MyPermobilClientException): + # the code did not pass validation by the api client + # or the backend returned an error when trying to validate the code + _LOGGER.exception("Error verifying code") + errors["base"] = "invalid_code" + + if errors or not user_input: + return self.async_show_form( + step_id="email_code", data_schema=GET_TOKEN_SCHEMA, errors=errors + ) + + return self.async_create_entry(title=self.data[CONF_EMAIL], data=self.data) + + async def async_step_reauth(self, user_input: Mapping[str, Any]) -> FlowResult: + """Perform reauth upon an API authentication error.""" + reauth_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + assert reauth_entry + + try: + email: str = reauth_entry.data[CONF_EMAIL] + region: str = reauth_entry.data[CONF_REGION] + self.p_api.set_email(email) + self.p_api.set_region(region) + self.data = { + CONF_EMAIL: email, + CONF_REGION: region, + } + await self.p_api.request_application_code() + except MyPermobilAPIException: + _LOGGER.exception("Error requesting code for reauth") + return self.async_abort(reason="unknown") + + return await self.async_step_email_code() diff --git a/homeassistant/components/permobil/const.py b/homeassistant/components/permobil/const.py new file mode 100644 index 00000000000..fd5fe673f2a --- /dev/null +++ b/homeassistant/components/permobil/const.py @@ -0,0 +1,11 @@ +"""Constants for the MyPermobil integration.""" + +DOMAIN = "permobil" + +APPLICATION = "Home Assistant" + + +BATTERY_ASSUMED_VOLTAGE = 25.0 # This is the average voltage over all states of charge +REGIONS = "regions" +KM = "kilometers" +MILES = "miles" diff --git a/homeassistant/components/permobil/coordinator.py b/homeassistant/components/permobil/coordinator.py new file mode 100644 index 00000000000..3695236cdf0 --- /dev/null +++ b/homeassistant/components/permobil/coordinator.py @@ -0,0 +1,57 @@ +"""DataUpdateCoordinator for permobil integration.""" + +import asyncio +from dataclasses import dataclass +from datetime import timedelta +import logging + +from mypermobil import MyPermobil, MyPermobilAPIException + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class MyPermobilData: + """MyPermobil data stored in the DataUpdateCoordinator.""" + + battery: dict[str, str | float | int | list | dict] + daily_usage: dict[str, str | float | int | list | dict] + records: dict[str, str | float | int | list | dict] + + +class MyPermobilCoordinator(DataUpdateCoordinator[MyPermobilData]): + """MyPermobil coordinator.""" + + def __init__(self, hass: HomeAssistant, p_api: MyPermobil) -> None: + """Initialize my coordinator.""" + super().__init__( + hass, + _LOGGER, + name="permobil", + update_interval=timedelta(minutes=5), + ) + self.p_api = p_api + + async def _async_update_data(self) -> MyPermobilData: + """Fetch data from the 3 API endpoints.""" + try: + async with asyncio.timeout(10): + battery = await self.p_api.get_battery_info() + daily_usage = await self.p_api.get_daily_usage() + records = await self.p_api.get_usage_records() + return MyPermobilData( + battery=battery, + daily_usage=daily_usage, + records=records, + ) + + except MyPermobilAPIException as err: + _LOGGER.exception( + "Error fetching data from MyPermobil API for account %s %s", + self.p_api.email, + err, + ) + raise UpdateFailed from err diff --git a/homeassistant/components/permobil/manifest.json b/homeassistant/components/permobil/manifest.json new file mode 100644 index 00000000000..fd937fc6f8a --- /dev/null +++ b/homeassistant/components/permobil/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "permobil", + "name": "MyPermobil", + "codeowners": ["@IsakNyberg"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/permobil", + "iot_class": "cloud_polling", + "requirements": ["mypermobil==0.1.6"] +} diff --git a/homeassistant/components/permobil/sensor.py b/homeassistant/components/permobil/sensor.py new file mode 100644 index 00000000000..e942aa265b8 --- /dev/null +++ b/homeassistant/components/permobil/sensor.py @@ -0,0 +1,222 @@ +"""Platform for sensor integration.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +import logging +from typing import Any + +from mypermobil import ( + BATTERY_AMPERE_HOURS_LEFT, + BATTERY_CHARGE_TIME_LEFT, + BATTERY_DISTANCE_LEFT, + BATTERY_INDOOR_DRIVE_TIME, + BATTERY_MAX_AMPERE_HOURS, + BATTERY_MAX_DISTANCE_LEFT, + BATTERY_STATE_OF_CHARGE, + BATTERY_STATE_OF_HEALTH, + RECORDS_SEATING, + USAGE_ADJUSTMENTS, + USAGE_DISTANCE, +) + +from homeassistant import config_entries +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import PERCENTAGE, UnitOfEnergy, UnitOfLength, UnitOfTime +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import BATTERY_ASSUMED_VOLTAGE, DOMAIN +from .coordinator import MyPermobilCoordinator + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class PermobilRequiredKeysMixin: + """Mixin for required keys.""" + + value_fn: Callable[[Any], float | int] + available_fn: Callable[[Any], bool] + + +@dataclass +class PermobilSensorEntityDescription( + SensorEntityDescription, PermobilRequiredKeysMixin +): + """Describes Permobil sensor entity.""" + + +SENSOR_DESCRIPTIONS: tuple[PermobilSensorEntityDescription, ...] = ( + PermobilSensorEntityDescription( + # Current battery as a percentage + value_fn=lambda data: data.battery[BATTERY_STATE_OF_CHARGE[0]], + available_fn=lambda data: BATTERY_STATE_OF_CHARGE[0] in data.battery, + key="state_of_charge", + translation_key="state_of_charge", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.BATTERY, + state_class=SensorStateClass.MEASUREMENT, + ), + PermobilSensorEntityDescription( + # Current battery health as a percentage of original capacity + value_fn=lambda data: data.battery[BATTERY_STATE_OF_HEALTH[0]], + available_fn=lambda data: BATTERY_STATE_OF_HEALTH[0] in data.battery, + key="state_of_health", + translation_key="state_of_health", + icon="mdi:battery-heart-variant", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + ), + PermobilSensorEntityDescription( + # Time until fully charged (displays 0 if not charging) + value_fn=lambda data: data.battery[BATTERY_CHARGE_TIME_LEFT[0]], + available_fn=lambda data: BATTERY_CHARGE_TIME_LEFT[0] in data.battery, + key="charge_time_left", + translation_key="charge_time_left", + icon="mdi:battery-clock", + native_unit_of_measurement=UnitOfTime.HOURS, + device_class=SensorDeviceClass.DURATION, + ), + PermobilSensorEntityDescription( + # Distance possible on current change (km) + value_fn=lambda data: data.battery[BATTERY_DISTANCE_LEFT[0]], + available_fn=lambda data: BATTERY_DISTANCE_LEFT[0] in data.battery, + key="distance_left", + translation_key="distance_left", + icon="mdi:map-marker-distance", + native_unit_of_measurement=UnitOfLength.KILOMETERS, + device_class=SensorDeviceClass.DISTANCE, + ), + PermobilSensorEntityDescription( + # Drive time possible on current charge + value_fn=lambda data: data.battery[BATTERY_INDOOR_DRIVE_TIME[0]], + available_fn=lambda data: BATTERY_INDOOR_DRIVE_TIME[0] in data.battery, + key="indoor_drive_time", + translation_key="indoor_drive_time", + native_unit_of_measurement=UnitOfTime.HOURS, + device_class=SensorDeviceClass.DURATION, + ), + PermobilSensorEntityDescription( + # Watt hours the battery can store given battery health + value_fn=lambda data: data.battery[BATTERY_MAX_AMPERE_HOURS[0]] + * BATTERY_ASSUMED_VOLTAGE, + available_fn=lambda data: BATTERY_MAX_AMPERE_HOURS[0] in data.battery, + key="max_watt_hours", + translation_key="max_watt_hours", + icon="mdi:lightning-bolt", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + device_class=SensorDeviceClass.ENERGY_STORAGE, + state_class=SensorStateClass.MEASUREMENT, + ), + PermobilSensorEntityDescription( + # Current amount of watt hours in battery + value_fn=lambda data: data.battery[BATTERY_AMPERE_HOURS_LEFT[0]] + * BATTERY_ASSUMED_VOLTAGE, + available_fn=lambda data: BATTERY_AMPERE_HOURS_LEFT[0] in data.battery, + key="watt_hours_left", + translation_key="watt_hours_left", + icon="mdi:lightning-bolt", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + device_class=SensorDeviceClass.ENERGY_STORAGE, + state_class=SensorStateClass.MEASUREMENT, + ), + PermobilSensorEntityDescription( + # Distance that can be traveled with full charge given battery health (km) + value_fn=lambda data: data.battery[BATTERY_MAX_DISTANCE_LEFT[0]], + available_fn=lambda data: BATTERY_MAX_DISTANCE_LEFT[0] in data.battery, + key="max_distance_left", + translation_key="max_distance_left", + icon="mdi:map-marker-distance", + native_unit_of_measurement=UnitOfLength.KILOMETERS, + device_class=SensorDeviceClass.DISTANCE, + ), + PermobilSensorEntityDescription( + # Distance traveled today monotonically increasing, resets every 24h (km) + value_fn=lambda data: data.daily_usage[USAGE_DISTANCE[0]], + available_fn=lambda data: USAGE_DISTANCE[0] in data.daily_usage, + key="usage_distance", + translation_key="usage_distance", + icon="mdi:map-marker-distance", + native_unit_of_measurement=UnitOfLength.KILOMETERS, + device_class=SensorDeviceClass.DISTANCE, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + PermobilSensorEntityDescription( + # Number of adjustments monotonically increasing, resets every 24h + value_fn=lambda data: data.daily_usage[USAGE_ADJUSTMENTS[0]], + available_fn=lambda data: USAGE_ADJUSTMENTS[0] in data.daily_usage, + key="usage_adjustments", + translation_key="usage_adjustments", + icon="mdi:seat-recline-extra", + native_unit_of_measurement="adjustments", + state_class=SensorStateClass.TOTAL_INCREASING, + ), + PermobilSensorEntityDescription( + # Largest number of adjustemnts in a single 24h period, never resets + value_fn=lambda data: data.records[RECORDS_SEATING[0]], + available_fn=lambda data: RECORDS_SEATING[0] in data.records, + key="record_adjustments", + translation_key="record_adjustments", + icon="mdi:seat-recline-extra", + native_unit_of_measurement="adjustments", + state_class=SensorStateClass.TOTAL_INCREASING, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: config_entries.ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Create sensors from a config entry created in the integrations UI.""" + + coordinator: MyPermobilCoordinator = hass.data[DOMAIN][config_entry.entry_id] + + async_add_entities( + PermobilSensor(coordinator=coordinator, description=description) + for description in SENSOR_DESCRIPTIONS + ) + + +class PermobilSensor(CoordinatorEntity[MyPermobilCoordinator], SensorEntity): + """Representation of a Sensor. + + This implements the common functions of all sensors. + """ + + _attr_has_entity_name = True + _attr_suggested_display_precision = 0 + entity_description: PermobilSensorEntityDescription + _available = True + + def __init__( + self, + coordinator: MyPermobilCoordinator, + description: PermobilSensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator=coordinator) + self.entity_description = description + self._attr_unique_id = ( + f"{coordinator.p_api.email}_{self.entity_description.key}" + ) + + @property + def available(self) -> bool: + """Return True if the sensor has value.""" + return super().available and self.entity_description.available_fn( + self.coordinator.data + ) + + @property + def native_value(self) -> float | int: + """Return the value of the sensor.""" + return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/permobil/strings.json b/homeassistant/components/permobil/strings.json new file mode 100644 index 00000000000..b0b630eff08 --- /dev/null +++ b/homeassistant/components/permobil/strings.json @@ -0,0 +1,70 @@ +{ + "config": { + "step": { + "user": { + "data": { + "email": "Enter your permobil email" + } + }, + "email_code": { + "description": "Enter the code that was sent to your email.", + "data": { + "code": "Email code" + } + }, + "region": { + "description": "Select the region of your account.", + "data": { + "code": "Region" + } + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + }, + "error": { + "unknown": "Unexpected error, more information in the logs", + "region_fetch_error": "Error fetching regions", + "code_request_error": "Error requesting application code", + "invalid_email": "Invalid email", + "invalid_code": "The code you gave is incorrect" + } + }, + "entity": { + "sensor": { + "state_of_charge": { + "name": "Battery charge" + }, + "state_of_health": { + "name": "Battery health" + }, + "charge_time_left": { + "name": "Charge time left" + }, + "distance_left": { + "name": "Distance left" + }, + "indoor_drive_time": { + "name": "Indoor drive time" + }, + "max_watt_hours": { + "name": "Battery max watt hours" + }, + "watt_hours_left": { + "name": "Watt hours left" + }, + "max_distance_left": { + "name": "Full charge distance" + }, + "usage_distance": { + "name": "Distance traveled" + }, + "usage_adjustments": { + "name": "Number of adjustments" + }, + "record_adjustments": { + "name": "Record number of adjustments" + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 3bbed6d145b..3aa738731b0 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -354,6 +354,7 @@ FLOWS = { "panasonic_viera", "peco", "pegel_online", + "permobil", "philips_js", "pi_hole", "picnic", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 50fb66c5f59..00ec549fd6d 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -4242,6 +4242,12 @@ "integration_type": "virtual", "supported_by": "opower" }, + "permobil": { + "name": "MyPermobil", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "pge": { "name": "Pacific Gas & Electric (PG&E)", "integration_type": "virtual", diff --git a/requirements_all.txt b/requirements_all.txt index 19dba37b23b..fd0c3225958 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1279,6 +1279,9 @@ mutagen==1.47.0 # homeassistant.components.mutesync mutesync==0.0.1 +# homeassistant.components.permobil +mypermobil==0.1.6 + # homeassistant.components.nad nad-receiver==0.3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fbc10c2b45d..f440046b5a6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1003,6 +1003,9 @@ mutagen==1.47.0 # homeassistant.components.mutesync mutesync==0.0.1 +# homeassistant.components.permobil +mypermobil==0.1.6 + # homeassistant.components.keenetic_ndms2 ndms2-client==0.1.2 diff --git a/tests/components/permobil/__init__.py b/tests/components/permobil/__init__.py new file mode 100644 index 00000000000..56e779eef4d --- /dev/null +++ b/tests/components/permobil/__init__.py @@ -0,0 +1 @@ +"""Tests for the MyPermobil integration.""" diff --git a/tests/components/permobil/conftest.py b/tests/components/permobil/conftest.py new file mode 100644 index 00000000000..2dcf9bd5ad2 --- /dev/null +++ b/tests/components/permobil/conftest.py @@ -0,0 +1,27 @@ +"""Common fixtures for the MyPermobil tests.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, Mock, patch + +from mypermobil import MyPermobil +import pytest + +from .const import MOCK_REGION_NAME, MOCK_TOKEN, MOCK_URL + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.permobil.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def my_permobil() -> Mock: + """Mock spec for MyPermobilApi.""" + mock = Mock(spec=MyPermobil) + mock.request_region_names.return_value = {MOCK_REGION_NAME: MOCK_URL} + mock.request_application_token.return_value = MOCK_TOKEN + mock.region = "" + return mock diff --git a/tests/components/permobil/const.py b/tests/components/permobil/const.py new file mode 100644 index 00000000000..cb8a0c32f17 --- /dev/null +++ b/tests/components/permobil/const.py @@ -0,0 +1,5 @@ +"""Test constants for Permobil.""" + +MOCK_URL = "https://example.com" +MOCK_REGION_NAME = "region_name" +MOCK_TOKEN = ("a" * 256, "date") diff --git a/tests/components/permobil/test_config_flow.py b/tests/components/permobil/test_config_flow.py new file mode 100644 index 00000000000..ad61ead7bfc --- /dev/null +++ b/tests/components/permobil/test_config_flow.py @@ -0,0 +1,288 @@ +"""Test the MyPermobil config flow.""" +from unittest.mock import Mock, patch + +from mypermobil import MyPermobilAPIException, MyPermobilClientException +import pytest + +from homeassistant import config_entries +from homeassistant.components.permobil import config_flow +from homeassistant.const import CONF_CODE, CONF_EMAIL, CONF_REGION, CONF_TOKEN, CONF_TTL +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .const import MOCK_REGION_NAME, MOCK_TOKEN, MOCK_URL + +from tests.common import MockConfigEntry + +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + +MOCK_CODE = "012345" +MOCK_EMAIL = "valid@email.com" +INVALID_EMAIL = "this is not a valid email" +VALID_DATA = { + CONF_EMAIL: MOCK_EMAIL, + CONF_REGION: MOCK_URL, + CONF_CODE: MOCK_CODE, + CONF_TOKEN: MOCK_TOKEN[0], + CONF_TTL: MOCK_TOKEN[1], +} + + +async def test_sucessful_config_flow(hass: HomeAssistant, my_permobil: Mock) -> None: + """Test the config flow from start to finish with no errors.""" + # init flow + with patch( + "homeassistant.components.permobil.config_flow.MyPermobil", + return_value=my_permobil, + ): + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={CONF_EMAIL: MOCK_EMAIL}, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "region" + assert result["errors"] == {} + + # select region step + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_REGION: MOCK_REGION_NAME}, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "email_code" + assert result["errors"] == {} + # request region code + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_CODE: MOCK_CODE}, + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == VALID_DATA + + +async def test_config_flow_incorrect_code( + hass: HomeAssistant, my_permobil: Mock +) -> None: + """Test the config flow from start to until email code verification and have the API return error.""" + my_permobil.request_application_token.side_effect = MyPermobilAPIException + # init flow + with patch( + "homeassistant.components.permobil.config_flow.MyPermobil", + return_value=my_permobil, + ): + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={CONF_EMAIL: MOCK_EMAIL}, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "region" + assert result["errors"] == {} + + # select region step + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_REGION: MOCK_REGION_NAME}, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "email_code" + assert result["errors"] == {} + + # request region code + # here the request_application_token raises a MyPermobilAPIException + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_CODE: MOCK_CODE}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "email_code" + assert result["errors"]["base"] == "invalid_code" + + +async def test_config_flow_incorrect_region( + hass: HomeAssistant, my_permobil: Mock +) -> None: + """Test the config flow from start to until the request for email code and have the API return error.""" + my_permobil.request_application_code.side_effect = MyPermobilAPIException + # init flow + with patch( + "homeassistant.components.permobil.config_flow.MyPermobil", + return_value=my_permobil, + ): + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={CONF_EMAIL: MOCK_EMAIL}, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "region" + assert result["errors"] == {} + + # select region step + # here the request_application_code raises a MyPermobilAPIException + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_REGION: MOCK_REGION_NAME}, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "region" + assert result["errors"]["base"] == "code_request_error" + + +async def test_config_flow_region_request_error( + hass: HomeAssistant, my_permobil: Mock +) -> None: + """Test the config flow from start to until the request for regions and have the API return error.""" + my_permobil.request_region_names.side_effect = MyPermobilAPIException + # init flow + # here the request_region_names raises a MyPermobilAPIException + with patch( + "homeassistant.components.permobil.config_flow.MyPermobil", + return_value=my_permobil, + ): + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={CONF_EMAIL: MOCK_EMAIL}, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "region" + assert result["errors"]["base"] == "region_fetch_error" + + +async def test_config_flow_invalid_email( + hass: HomeAssistant, my_permobil: Mock +) -> None: + """Test the config flow from start to until the request for regions and have the API return error.""" + my_permobil.set_email.side_effect = MyPermobilClientException() + # init flow + # here the set_email raises a MyPermobilClientException + with patch( + "homeassistant.components.permobil.config_flow.MyPermobil", + return_value=my_permobil, + ): + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={CONF_EMAIL: INVALID_EMAIL}, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == config_entries.SOURCE_USER + assert result["errors"]["base"] == "invalid_email" + + +async def test_config_flow_reauth_success( + hass: HomeAssistant, my_permobil: Mock +) -> None: + """Test the config flow reauth make sure that the values are replaced.""" + # new token and code + reauth_token = ("b" * 256, "reauth_date") + reauth_code = "567890" + my_permobil.request_application_token.return_value = reauth_token + + mock_entry = MockConfigEntry( + domain="permobil", + data=VALID_DATA, + ) + mock_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.permobil.config_flow.MyPermobil", + return_value=my_permobil, + ): + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, + context={"source": "reauth", "entry_id": mock_entry.entry_id}, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "email_code" + assert result["errors"] == {} + + # request request new token + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_CODE: reauth_code}, + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_EMAIL: MOCK_EMAIL, + CONF_REGION: MOCK_URL, + CONF_CODE: reauth_code, + CONF_TOKEN: reauth_token[0], + CONF_TTL: reauth_token[1], + } + + +async def test_config_flow_reauth_fail_invalid_code( + hass: HomeAssistant, my_permobil: Mock +) -> None: + """Test the config flow reauth when the email code fails.""" + # new code + reauth_invalid_code = "567890" # pretend this code is invalid/incorrect + my_permobil.request_application_token.side_effect = MyPermobilAPIException + mock_entry = MockConfigEntry( + domain="permobil", + data=VALID_DATA, + ) + mock_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.permobil.config_flow.MyPermobil", + return_value=my_permobil, + ): + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, + context={"source": "reauth", "entry_id": mock_entry.entry_id}, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "email_code" + assert result["errors"] == {} + + # request request new token but have the API return error + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_CODE: reauth_invalid_code}, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "email_code" + assert result["errors"]["base"] == "invalid_code" + + +async def test_config_flow_reauth_fail_code_request( + hass: HomeAssistant, my_permobil: Mock +) -> None: + """Test the config flow reauth.""" + my_permobil.request_application_code.side_effect = MyPermobilAPIException + mock_entry = MockConfigEntry( + domain="permobil", + data=VALID_DATA, + ) + mock_entry.add_to_hass(hass) + # test the reauth and have request_application_code fail leading to an abort + my_permobil.request_application_code.side_effect = MyPermobilAPIException + reauth_entry = hass.config_entries.async_entries(config_flow.DOMAIN)[0] + with patch( + "homeassistant.components.permobil.config_flow.MyPermobil", + return_value=my_permobil, + ): + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, + context={"source": "reauth", "entry_id": reauth_entry.entry_id}, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "unknown"