Add Mypermobil integration (#95613)

Co-authored-by: Franck Nijhof <git@frenck.dev>
Co-authored-by: Robert Resch <robert@resch.dev>
This commit is contained in:
Isak Nyberg 2023-11-24 10:40:59 +01:00 committed by GitHub
parent 114ca70961
commit e03ccb5ab6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 944 additions and 0 deletions

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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()

View File

@ -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"

View File

@ -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

View File

@ -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"]
}

View File

@ -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)

View File

@ -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"
}
}
}
}

View File

@ -354,6 +354,7 @@ FLOWS = {
"panasonic_viera",
"peco",
"pegel_online",
"permobil",
"philips_js",
"pi_hole",
"picnic",

View File

@ -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",

View File

@ -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

View File

@ -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

View File

@ -0,0 +1 @@
"""Tests for the MyPermobil integration."""

View File

@ -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

View File

@ -0,0 +1,5 @@
"""Test constants for Permobil."""
MOCK_URL = "https://example.com"
MOCK_REGION_NAME = "region_name"
MOCK_TOKEN = ("a" * 256, "date")

View File

@ -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"