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 all 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
3 changes: 3 additions & 0 deletions .coveragerc
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions CODEOWNERS
Validating CODEOWNERS rules …
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
63 changes: 63 additions & 0 deletions homeassistant/components/permobil/__init__.py
Original file line number Diff line number Diff line change
@@ -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:
IsakNyberg marked this conversation as resolved.
Show resolved Hide resolved
_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
173 changes: 173 additions & 0 deletions homeassistant/components/permobil/config_flow.py
Original file line number Diff line number Diff line change
@@ -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] = {}

IsakNyberg marked this conversation as resolved.
Show resolved Hide resolved
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()
11 changes: 11 additions & 0 deletions homeassistant/components/permobil/const.py
Original file line number Diff line number Diff line change
@@ -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"
57 changes: 57 additions & 0 deletions homeassistant/components/permobil/coordinator.py
Original file line number Diff line number Diff line change
@@ -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
IsakNyberg marked this conversation as resolved.
Show resolved Hide resolved
9 changes: 9 additions & 0 deletions homeassistant/components/permobil/manifest.json
Original file line number Diff line number Diff line change
@@ -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"]
}
Loading
Loading