mirror of
https://github.com/home-assistant/core.git
synced 2025-04-23 08:47:57 +00:00
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:
parent
114ca70961
commit
e03ccb5ab6
@ -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
|
||||
|
@ -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
|
||||
|
63
homeassistant/components/permobil/__init__.py
Normal file
63
homeassistant/components/permobil/__init__.py
Normal 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
|
173
homeassistant/components/permobil/config_flow.py
Normal file
173
homeassistant/components/permobil/config_flow.py
Normal 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()
|
11
homeassistant/components/permobil/const.py
Normal file
11
homeassistant/components/permobil/const.py
Normal 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"
|
57
homeassistant/components/permobil/coordinator.py
Normal file
57
homeassistant/components/permobil/coordinator.py
Normal 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
|
9
homeassistant/components/permobil/manifest.json
Normal file
9
homeassistant/components/permobil/manifest.json
Normal 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"]
|
||||
}
|
222
homeassistant/components/permobil/sensor.py
Normal file
222
homeassistant/components/permobil/sensor.py
Normal 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)
|
70
homeassistant/components/permobil/strings.json
Normal file
70
homeassistant/components/permobil/strings.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -354,6 +354,7 @@ FLOWS = {
|
||||
"panasonic_viera",
|
||||
"peco",
|
||||
"pegel_online",
|
||||
"permobil",
|
||||
"philips_js",
|
||||
"pi_hole",
|
||||
"picnic",
|
||||
|
@ -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",
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
1
tests/components/permobil/__init__.py
Normal file
1
tests/components/permobil/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Tests for the MyPermobil integration."""
|
27
tests/components/permobil/conftest.py
Normal file
27
tests/components/permobil/conftest.py
Normal 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
|
5
tests/components/permobil/const.py
Normal file
5
tests/components/permobil/const.py
Normal file
@ -0,0 +1,5 @@
|
||||
"""Test constants for Permobil."""
|
||||
|
||||
MOCK_URL = "https://example.com"
|
||||
MOCK_REGION_NAME = "region_name"
|
||||
MOCK_TOKEN = ("a" * 256, "date")
|
288
tests/components/permobil/test_config_flow.py
Normal file
288
tests/components/permobil/test_config_flow.py
Normal 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"
|
Loading…
x
Reference in New Issue
Block a user