Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Mypermobil integration #95613

Merged
merged 54 commits into from
Nov 24, 2023
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
54 commits
Select commit Hold shift + click to select a range
8ef5c87
add new integration MyPermobil
IsakNyberg Jun 28, 2023
f306e30
add config flow test
IsakNyberg Jun 29, 2023
bf36a2e
improve documentation
IsakNyberg Jun 30, 2023
4d82753
update permobil api version
IsakNyberg Jun 30, 2023
4053ae7
bump MyPermobil version to 0.1.3
IsakNyberg Jul 3, 2023
e48ee3a
add handling error in sensor setup entry
IsakNyberg Jul 4, 2023
6fbc7ef
remove unnecessary information in manifest
IsakNyberg Jul 25, 2023
42c0910
add coordinator
IsakNyberg Jul 25, 2023
2b06cc7
remove redundant strings
IsakNyberg Jul 25, 2023
33fce5d
change sensor classes to sensor descriptions
IsakNyberg Jul 26, 2023
40bc8b3
Merge branch 'dev' into mypermobil
IsakNyberg Jul 26, 2023
3cf4820
remove coordinator dict inside hass.data
IsakNyberg Jul 26, 2023
d5960df
add translation to sensors
IsakNyberg Jul 26, 2023
81e6fb3
revise sensor states and classes
IsakNyberg Jul 26, 2023
c05160a
add return value for coordinator
IsakNyberg Jul 26, 2023
370057d
change config_entry title and id to user email
IsakNyberg Jul 28, 2023
19385b0
Merge branch 'dev' into mypermobil
IsakNyberg Sep 21, 2023
c7f0968
update imports to comply with ruff check
IsakNyberg Sep 21, 2023
e0515ad
add coordinator and init to .coveragerc
IsakNyberg Sep 21, 2023
09bc77d
address comments on pr
IsakNyberg Oct 11, 2023
48baa67
replace async_timeout with asyncio.timeout
IsakNyberg Oct 11, 2023
6d8dc64
improve config_flow schema
IsakNyberg Oct 12, 2023
41b129c
remove whitespace replacement from email
IsakNyberg Oct 12, 2023
8e4a8fb
update error dict format
IsakNyberg Oct 12, 2023
7720f48
add reauth config flow
IsakNyberg Oct 14, 2023
3cb76e3
remove non-essential comments and code
IsakNyberg Oct 15, 2023
e72294b
remove initialization check for api in config flow
IsakNyberg Oct 15, 2023
fe740e1
fix formatting
IsakNyberg Oct 16, 2023
742ed84
update verification method in config flow
IsakNyberg Oct 17, 2023
4d4d47a
remove verification for empty email code
IsakNyberg Oct 17, 2023
58a1c83
move config flow api initialization to init
IsakNyberg Oct 17, 2023
3666261
update loggin strings
IsakNyberg Oct 17, 2023
5228930
small fix to if statements
IsakNyberg Oct 18, 2023
b251452
add abort for reauth error
IsakNyberg Oct 19, 2023
8f0dcbc
bump mypermobil to 0.1.6
IsakNyberg Oct 19, 2023
f9f0262
Merge branch 'dev' into mypermobil
frenck Oct 19, 2023
b792441
small fixes
IsakNyberg Oct 24, 2023
e789366
overhaul tests
IsakNyberg Oct 26, 2023
28f21d1
fix sensor descriptions keys
IsakNyberg Oct 26, 2023
4aa296d
add marge init test with config flow test
IsakNyberg Oct 26, 2023
500e513
add availability property to sensor
IsakNyberg Oct 26, 2023
876c806
change set_email function to mock
IsakNyberg Oct 28, 2023
31023ab
fix test warnings
IsakNyberg Oct 30, 2023
a2a1eb5
change key to string
IsakNyberg Oct 30, 2023
f1b95e4
add available_fn to all sensors
IsakNyberg Oct 30, 2023
cdab8be
update sensor function types
IsakNyberg Oct 31, 2023
67e2a0e
add separate reauth flow tests
IsakNyberg Oct 31, 2023
b6e4b3e
add icons to some sensors
IsakNyberg Oct 31, 2023
a067794
add data entry check to reauth flow test
IsakNyberg Nov 6, 2023
d9ac205
fix data dict comparions in test
IsakNyberg Nov 18, 2023
498435e
add mock spec
IsakNyberg Nov 21, 2023
6040d58
Merge branch 'dev' into mypermobil
edenhaus Nov 24, 2023
9726d50
Remove MOCK_SPEC class
edenhaus Nov 24, 2023
54c599d
clean up
edenhaus Nov 24, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .coveragerc
Original file line number Diff line number Diff line change
Expand Up @@ -891,6 +891,7 @@ omit =
homeassistant/components/panasonic_viera/media_player.py
homeassistant/components/pandora/media_player.py
homeassistant/components/pencom/switch.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
Expand Down
2 changes: 2 additions & 0 deletions CODEOWNERS
Validating CODEOWNERS rules …
Original file line number Diff line number Diff line change
Expand Up @@ -914,6 +914,8 @@ build.json @home-assistant/supervisor
/tests/components/panel_iframe/ @home-assistant/frontend
/homeassistant/components/peco/ @IceBotYT
/tests/components/peco/ @IceBotYT
/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
Expand Down
28 changes: 28 additions & 0 deletions homeassistant/components/permobil/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
"""The MyPermobil integration."""
from __future__ import annotations

from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant

from .const import DOMAIN

PLATFORMS: list[Platform] = [Platform.SENSOR]


async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up MyPermobil from a config entry."""

hass.data.setdefault(DOMAIN, {})
hass.data[DOMAIN][entry.entry_id] = entry.data
IsakNyberg marked this conversation as resolved.
Show resolved Hide resolved
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
224 changes: 224 additions & 0 deletions homeassistant/components/permobil/config_flow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
"""Config flow for MyPermobil integration."""
from __future__ import annotations

import logging
from typing import Any

from mypermobil import MyPermobil, MyPermobilAPIException, MyPermobilClientException
import voluptuous as vol

from homeassistant import config_entries, exceptions
from homeassistant.const import (
CONF_CODE,
CONF_EMAIL,
CONF_REGION,
CONF_TOKEN,
CONF_TTL,
)
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 .const import APPLICATION, DOMAIN

_LOGGER = logging.getLogger(__name__)

GET_EMAIL_SCHEMA = vol.Schema(
{
vol.Required(CONF_EMAIL): cv.string,
IsakNyberg marked this conversation as resolved.
Show resolved Hide resolved
}
)

GET_TOKEN_SCHEMA = vol.Schema({vol.Required(CONF_CODE): cv.string})


class InvalidAuth(exceptions.HomeAssistantError):
"""Error to indicate we cannot connect."""


async def validate_input(p_api: MyPermobil, data: dict[str, Any]) -> None:
"""Validate the user input allows us to connect."""
email = data.get(CONF_EMAIL)
code = data.get(CONF_CODE)
token = data.get(CONF_TOKEN)
if email:
p_api.set_email(email)
if code:
p_api.set_code(code)
if token:
p_api.set_token(token)
IsakNyberg marked this conversation as resolved.
Show resolved Hide resolved


class PermobilConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Permobil config flow."""

# The schema version of the entries that it creates
# Home Assistant will call your migrate method if the version changes
VERSION = 1
p_api: MyPermobil = None
region_names = {"Failed to load regions": ""}
data = {
CONF_EMAIL: "",
CONF_REGION: "",
CONF_CODE: "",
CONF_TOKEN: "",
CONF_TTL: "",
}

IsakNyberg marked this conversation as resolved.
Show resolved Hide resolved
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."""
# IDEA: in the future allow for multiple accounts
await self.async_set_unique_id(DOMAIN)
self._abort_if_unique_id_configured()
errors: dict[str, str] = {}
if not self.p_api:
IsakNyberg marked this conversation as resolved.
Show resolved Hide resolved
# create the api instance to use for validation of the user input
session = async_get_clientsession(self.hass)
self.p_api = MyPermobil(APPLICATION, session=session)

try:
if user_input is not None:
if not user_input.get(CONF_EMAIL):
raise InvalidAuth("empty email")
IsakNyberg marked this conversation as resolved.
Show resolved Hide resolved

# the user has entered their email in the first prompt
user_input[CONF_EMAIL] = user_input[CONF_EMAIL].replace(" ", "")
IsakNyberg marked this conversation as resolved.
Show resolved Hide resolved
await validate_input(self.p_api, user_input) # ClientException
self.data[CONF_EMAIL] = user_input[CONF_EMAIL]
_LOGGER.debug("Permobil: email %s", self.p_api.email)
except MyPermobilClientException as err:
# the email did not pass validation by the api client
_LOGGER.error("Permobil: %s", err)
errors["base"] = f"Pemobil: {err}"
errors["reason"] = "invalid_email"
except InvalidAuth as err:
_LOGGER.error("Permobil: %s", err)
errors["base"] = "Empty Email"
errors["reason"] = "empty_email"
IsakNyberg marked this conversation as resolved.
Show resolved Hide resolved

if errors or user_input is None:
# There was an error when the user entered their email
# or the user opened the flow for the first time
return self.async_show_form(
step_id="user", data_schema=GET_EMAIL_SCHEMA, errors=errors
)
# email prompt finished successfully, open the select region prompt
return await self.async_step_region()

async def async_step_region(self, user_input=None) -> FlowResult:
IsakNyberg marked this conversation as resolved.
Show resolved Hide resolved
"""Invoke when a user initiates a flow via the user interface."""
errors: dict[str, str] = {}
if not self.p_api:
# create the api for getting regions and sending the code
session = async_get_clientsession(self.hass)
self.p_api = MyPermobil(APPLICATION, session=session)

try:
if user_input is None:
# The user has opened the 2nd prompt for the first time
# if the email ends with @permobil,
# include internal regions for debugging purposes
include_internal = self.data[CONF_EMAIL].endswith("@permobil.com")
_LOGGER.debug("Permobil: include internals %s", include_internal)
IsakNyberg marked this conversation as resolved.
Show resolved Hide resolved
# fetch the list of regions names and urls from the api
# for the user to select from. [("name","url"),("name","url"),...]
self.region_names = await self.p_api.request_region_names(
include_internal
)
_LOGGER.debug(
"Permobil: region names %s",
",".join(list(self.region_names.keys())),
)

else:
# the user has selected their region name in the second prompt
# find the url for the selected region name
region_url = self.region_names[user_input[CONF_REGION]] # KeyError
# set the region url in the api instance and in the entry
self.data[CONF_REGION] = region_url
self.p_api.set_region(region_url)
_LOGGER.debug("Permobil: region %s", self.p_api.region)
# tell backend to send code to the users email
# the code will be entered in the next prompt
await self.p_api.request_application_code() # MyPermobilAPIException
IsakNyberg marked this conversation as resolved.
Show resolved Hide resolved
except KeyError as err:
# the user has selected a region name that is not in the list (somehow)
errors["base"] = f"Pemobil: {err}"
IsakNyberg marked this conversation as resolved.
Show resolved Hide resolved
errors["reason"] = "invalid_region"
except MyPermobilAPIException as err:
# the backend has returned an error
errors["base"] = f"Pemobil: {err}"
errors["reason"] = "connection_error"

if errors or user_input is None:
# There was an error when the user selected their region
# or the backend returned an error when trying to send the code
# or the user opened the second prompt for the first time

# create the schema for the second prompt, a list of region names
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
)

# open the email code prompt
return await self.async_step_email_code()

async def async_step_email_code(self, user_input=None) -> FlowResult:
IsakNyberg marked this conversation as resolved.
Show resolved Hide resolved
"""Second step in config flow to enter the email code."""
errors: dict[str, str] = {}
if not self.p_api:
# create the api to validate the code and to get token
session = async_get_clientsession(self.hass)
self.p_api = MyPermobil(APPLICATION, session=session)

try:
if user_input is not None:
IsakNyberg marked this conversation as resolved.
Show resolved Hide resolved
if not user_input.get(CONF_CODE):
raise InvalidAuth("empty code")
IsakNyberg marked this conversation as resolved.
Show resolved Hide resolved

# the user has entered data in the second prompt
# set the data
user_input[CONF_CODE] = user_input[CONF_CODE].replace(" ", "")
IsakNyberg marked this conversation as resolved.
Show resolved Hide resolved
await validate_input(self.p_api, user_input) # ClientException
self.data[CONF_CODE] = user_input[CONF_CODE]
IsakNyberg marked this conversation as resolved.
Show resolved Hide resolved
_LOGGER.debug("Permobil: code %s…", self.data[CONF_CODE][:3])
resp = await self.p_api.request_application_token() # APIException
token, ttl = resp # get token and ttl from the response
self.data[CONF_TOKEN] = token
self.data[CONF_TTL] = ttl
_LOGGER.debug("Permobil: token %s…", self.data[CONF_TOKEN][:5])
_LOGGER.debug("Permobil: ttl %s", self.data[CONF_TTL])
except (MyPermobilAPIException, MyPermobilClientException) as err:
# the code did not pass validation by the api client
# or the backend returned an error when trying to validate the code
_LOGGER.error("Permobil: %s", err)
errors["base"] = f"Pemobil: {err}"
errors["reason"] = "invalid_code"
except InvalidAuth as err:
_LOGGER.error("Permobil: %s", err)
errors["base"] = "Empty Code"
errors["reason"] = "empty_code"

if errors or user_input is None:
IsakNyberg marked this conversation as resolved.
Show resolved Hide resolved
# There was an error when the user entered their code
# or the user opened the third prompt for the first time
return self.async_show_form(
step_id="email_code", data_schema=GET_TOKEN_SCHEMA, errors=errors
)

# the entire flow finished successfully
# IDEA: in the future make the title unique for each account
return self.async_create_entry(title="Token", data=self.data)
12 changes: 12 additions & 0 deletions homeassistant/components/permobil/const.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
"""Constants for the MyPermobil integration."""

DOMAIN = "permobil"

APPLICATION = "Home Assistant"

API = "api"
IsakNyberg marked this conversation as resolved.
Show resolved Hide resolved
REGIONS = "regions"

BATTERY_ASSUMED_VOLTAGE = 25.0 # This is the average voltage over all states of charge
KM = "kilometers"
MILES = "miles"
13 changes: 13 additions & 0 deletions homeassistant/components/permobil/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"domain": "permobil",
"name": "MyPermobil",
"codeowners": ["@IsakNyberg"],
"config_flow": true,
"dependencies": [],
IsakNyberg marked this conversation as resolved.
Show resolved Hide resolved
"documentation": "https://www.home-assistant.io/integrations/permobil",
"homekit": {},
IsakNyberg marked this conversation as resolved.
Show resolved Hide resolved
"iot_class": "cloud_polling",
"requirements": ["mypermobil==0.1.3"],
"ssdp": [],
"zeroconf": []
IsakNyberg marked this conversation as resolved.
Show resolved Hide resolved
}
Loading
Loading