Refactor volvooncall to use ConfigFlow (#76680)

* refactor volvooncall to use ConfigFlow

* remove unused constant SIGNAL_STATE_UPDATED

* implemented feedback

* improve ConfigFlow UX by giving an option for region=None

* implemented more feedback

* next round of feedback

* implemented more feedback

* improve test coverage

* more test coverage

* Apply suggestions from code review

* implemented feedback on tests

* Apply suggestions from code review

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
This commit is contained in:
y34hbuddy 2022-08-23 14:58:17 -04:00 committed by GitHub
parent f61edf0778
commit 99ec341d6e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 705 additions and 181 deletions

View File

@ -1427,7 +1427,14 @@ omit =
homeassistant/components/volumio/__init__.py homeassistant/components/volumio/__init__.py
homeassistant/components/volumio/browse_media.py homeassistant/components/volumio/browse_media.py
homeassistant/components/volumio/media_player.py homeassistant/components/volumio/media_player.py
homeassistant/components/volvooncall/* homeassistant/components/volvooncall/__init__.py
homeassistant/components/volvooncall/binary_sensor.py
homeassistant/components/volvooncall/const.py
homeassistant/components/volvooncall/device_tracker.py
homeassistant/components/volvooncall/errors.py
homeassistant/components/volvooncall/lock.py
homeassistant/components/volvooncall/sensor.py
homeassistant/components/volvooncall/switch.py
homeassistant/components/vulcan/__init__.py homeassistant/components/vulcan/__init__.py
homeassistant/components/vulcan/calendar.py homeassistant/components/vulcan/calendar.py
homeassistant/components/vulcan/fetch_data.py homeassistant/components/vulcan/fetch_data.py

View File

@ -1201,6 +1201,7 @@ build.json @home-assistant/supervisor
/homeassistant/components/volumio/ @OnFreund /homeassistant/components/volumio/ @OnFreund
/tests/components/volumio/ @OnFreund /tests/components/volumio/ @OnFreund
/homeassistant/components/volvooncall/ @molobrakos /homeassistant/components/volvooncall/ @molobrakos
/tests/components/volvooncall/ @molobrakos
/homeassistant/components/vulcan/ @Antoni-Czaplicki /homeassistant/components/vulcan/ @Antoni-Czaplicki
/tests/components/vulcan/ @Antoni-Czaplicki /tests/components/vulcan/ @Antoni-Czaplicki
/homeassistant/components/wake_on_lan/ @ntilley905 /homeassistant/components/wake_on_lan/ @ntilley905

View File

@ -1,12 +1,15 @@
"""Support for Volvo On Call.""" """Support for Volvo On Call."""
from datetime import timedelta
import logging import logging
from aiohttp.client_exceptions import ClientResponseError
import async_timeout import async_timeout
import voluptuous as vol import voluptuous as vol
from volvooncall import Connection from volvooncall import Connection
from volvooncall.dashboard import Instrument from volvooncall.dashboard import Instrument
from homeassistant.components.repairs import IssueSeverity, async_create_issue
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import ( from homeassistant.const import (
CONF_NAME, CONF_NAME,
CONF_PASSWORD, CONF_PASSWORD,
@ -16,7 +19,7 @@ from homeassistant.const import (
CONF_USERNAME, CONF_USERNAME,
) )
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers import discovery from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
import homeassistant.helpers.config_validation as cv import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.dispatcher import async_dispatcher_send
@ -27,121 +30,119 @@ from homeassistant.helpers.update_coordinator import (
UpdateFailed, UpdateFailed,
) )
DOMAIN = "volvooncall" from .const import (
CONF_MUTABLE,
DATA_KEY = DOMAIN CONF_SCANDINAVIAN_MILES,
CONF_SERVICE_URL,
DEFAULT_UPDATE_INTERVAL,
DOMAIN,
PLATFORMS,
RESOURCES,
VOLVO_DISCOVERY_NEW,
)
from .errors import InvalidAuth
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
DEFAULT_UPDATE_INTERVAL = timedelta(minutes=1)
CONF_SERVICE_URL = "service_url"
CONF_SCANDINAVIAN_MILES = "scandinavian_miles"
CONF_MUTABLE = "mutable"
SIGNAL_STATE_UPDATED = f"{DOMAIN}.updated"
PLATFORMS = {
"sensor": "sensor",
"binary_sensor": "binary_sensor",
"lock": "lock",
"device_tracker": "device_tracker",
"switch": "switch",
}
RESOURCES = [
"position",
"lock",
"heater",
"odometer",
"trip_meter1",
"trip_meter2",
"average_speed",
"fuel_amount",
"fuel_amount_level",
"average_fuel_consumption",
"distance_to_empty",
"washer_fluid_level",
"brake_fluid",
"service_warning_status",
"bulb_failures",
"battery_range",
"battery_level",
"time_to_fully_charged",
"battery_charge_status",
"engine_start",
"last_trip",
"is_engine_running",
"doors_hood_open",
"doors_tailgate_open",
"doors_front_left_door_open",
"doors_front_right_door_open",
"doors_rear_left_door_open",
"doors_rear_right_door_open",
"windows_front_left_window_open",
"windows_front_right_window_open",
"windows_rear_left_window_open",
"windows_rear_right_window_open",
"tyre_pressure_front_left_tyre_pressure",
"tyre_pressure_front_right_tyre_pressure",
"tyre_pressure_rear_left_tyre_pressure",
"tyre_pressure_rear_right_tyre_pressure",
"any_door_open",
"any_window_open",
]
CONFIG_SCHEMA = vol.Schema( CONFIG_SCHEMA = vol.Schema(
{ vol.All(
DOMAIN: vol.All( cv.deprecated(DOMAIN),
cv.deprecated(CONF_SCAN_INTERVAL), {
cv.deprecated(CONF_NAME), DOMAIN: vol.Schema(
cv.deprecated(CONF_RESOURCES),
vol.Schema(
{ {
vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_USERNAME): cv.string,
vol.Required(CONF_PASSWORD): cv.string, vol.Required(CONF_PASSWORD): cv.string,
vol.Optional( vol.Optional(
CONF_SCAN_INTERVAL, default=DEFAULT_UPDATE_INTERVAL CONF_SCAN_INTERVAL, default=DEFAULT_UPDATE_INTERVAL
): vol.All( ): vol.All(cv.time_period, vol.Clamp(min=DEFAULT_UPDATE_INTERVAL)),
cv.time_period, vol.Clamp(min=DEFAULT_UPDATE_INTERVAL)
), # ignored, using DataUpdateCoordinator instead
vol.Optional(CONF_NAME, default={}): cv.schema_with_slug_keys( vol.Optional(CONF_NAME, default={}): cv.schema_with_slug_keys(
cv.string cv.string
), # ignored, users can modify names of entities in the UI ),
vol.Optional(CONF_RESOURCES): vol.All( vol.Optional(CONF_RESOURCES): vol.All(
cv.ensure_list, [vol.In(RESOURCES)] cv.ensure_list, [vol.In(RESOURCES)]
), # ignored, users can disable entities in the UI ),
vol.Optional(CONF_REGION): cv.string, vol.Optional(CONF_REGION): cv.string,
vol.Optional(CONF_SERVICE_URL): cv.string, vol.Optional(CONF_SERVICE_URL): cv.string,
vol.Optional(CONF_MUTABLE, default=True): cv.boolean, vol.Optional(CONF_MUTABLE, default=True): cv.boolean,
vol.Optional(CONF_SCANDINAVIAN_MILES, default=False): cv.boolean, vol.Optional(CONF_SCANDINAVIAN_MILES, default=False): cv.boolean,
} }
), )
) },
}, ),
extra=vol.ALLOW_EXTRA, extra=vol.ALLOW_EXTRA,
) )
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Volvo On Call component.""" """Migrate from YAML to ConfigEntry."""
if DOMAIN not in config:
return True
hass.data[DOMAIN] = {}
if not hass.config_entries.async_entries(DOMAIN):
new_conf = {}
new_conf[CONF_USERNAME] = config[DOMAIN][CONF_USERNAME]
new_conf[CONF_PASSWORD] = config[DOMAIN][CONF_PASSWORD]
new_conf[CONF_REGION] = config[DOMAIN].get(CONF_REGION)
new_conf[CONF_SCANDINAVIAN_MILES] = config[DOMAIN][CONF_SCANDINAVIAN_MILES]
new_conf[CONF_MUTABLE] = config[DOMAIN][CONF_MUTABLE]
hass.async_create_task(
hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_IMPORT}, data=new_conf
)
)
async_create_issue(
hass,
DOMAIN,
"deprecated_yaml",
breaks_in_ha_version=None,
is_fixable=False,
severity=IssueSeverity.WARNING,
translation_key="deprecated_yaml",
)
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up the Volvo On Call component from a ConfigEntry."""
session = async_get_clientsession(hass) session = async_get_clientsession(hass)
connection = Connection( connection = Connection(
session=session, session=session,
username=config[DOMAIN].get(CONF_USERNAME), username=entry.data[CONF_USERNAME],
password=config[DOMAIN].get(CONF_PASSWORD), password=entry.data[CONF_PASSWORD],
service_url=config[DOMAIN].get(CONF_SERVICE_URL), service_url=None,
region=config[DOMAIN].get(CONF_REGION), region=entry.data[CONF_REGION],
) )
hass.data[DATA_KEY] = {} hass.data.setdefault(DOMAIN, {})
volvo_data = VolvoData(hass, connection, config) volvo_data = VolvoData(hass, connection, entry)
hass.data[DATA_KEY] = VolvoUpdateCoordinator(hass, volvo_data) coordinator = hass.data[DOMAIN][entry.entry_id] = VolvoUpdateCoordinator(
hass, volvo_data
)
return await volvo_data.update() try:
await coordinator.async_config_entry_first_refresh()
except ConfigEntryAuthFailed:
return False
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
class VolvoData: class VolvoData:
@ -151,13 +152,13 @@ class VolvoData:
self, self,
hass: HomeAssistant, hass: HomeAssistant,
connection: Connection, connection: Connection,
config: ConfigType, entry: ConfigEntry,
) -> None: ) -> None:
"""Initialize the component state.""" """Initialize the component state."""
self.hass = hass self.hass = hass
self.vehicles: set[str] = set() self.vehicles: set[str] = set()
self.instruments: set[Instrument] = set() self.instruments: set[Instrument] = set()
self.config = config self.config_entry = entry
self.connection = connection self.connection = connection
def instrument(self, vin, component, attr, slug_attr): def instrument(self, vin, component, attr, slug_attr):
@ -184,8 +185,8 @@ class VolvoData:
self.vehicles.add(vehicle.vin) self.vehicles.add(vehicle.vin)
dashboard = vehicle.dashboard( dashboard = vehicle.dashboard(
mutable=self.config[DOMAIN][CONF_MUTABLE], mutable=self.config_entry.data[CONF_MUTABLE],
scandinavian_miles=self.config[DOMAIN][CONF_SCANDINAVIAN_MILES], scandinavian_miles=self.config_entry.data[CONF_SCANDINAVIAN_MILES],
) )
for instrument in ( for instrument in (
@ -193,23 +194,8 @@ class VolvoData:
for instrument in dashboard.instruments for instrument in dashboard.instruments
if instrument.component in PLATFORMS if instrument.component in PLATFORMS
): ):
self.instruments.add(instrument) self.instruments.add(instrument)
async_dispatcher_send(self.hass, VOLVO_DISCOVERY_NEW, [instrument])
self.hass.async_create_task(
discovery.async_load_platform(
self.hass,
PLATFORMS[instrument.component],
DOMAIN,
(
vehicle.vin,
instrument.component,
instrument.attr,
instrument.slug_attr,
),
self.config,
)
)
async def update(self): async def update(self):
"""Update status from the online service.""" """Update status from the online service."""
@ -220,11 +206,15 @@ class VolvoData:
if vehicle.vin not in self.vehicles: if vehicle.vin not in self.vehicles:
self.discover_vehicle(vehicle) self.discover_vehicle(vehicle)
# this is currently still needed for device_tracker, which isn't using the update coordinator yet
async_dispatcher_send(self.hass, SIGNAL_STATE_UPDATED)
return True return True
async def auth_is_valid(self):
"""Check if provided username/password/region authenticate."""
try:
await self.connection.get("customeraccounts")
except ClientResponseError as exc:
raise InvalidAuth from exc
class VolvoUpdateCoordinator(DataUpdateCoordinator): class VolvoUpdateCoordinator(DataUpdateCoordinator):
"""Volvo coordinator.""" """Volvo coordinator."""

View File

@ -4,28 +4,54 @@ from __future__ import annotations
from contextlib import suppress from contextlib import suppress
import voluptuous as vol import voluptuous as vol
from volvooncall.dashboard import Instrument
from homeassistant.components.binary_sensor import ( from homeassistant.components.binary_sensor import (
DEVICE_CLASSES_SCHEMA, DEVICE_CLASSES_SCHEMA,
BinarySensorEntity, BinarySensorEntity,
) )
from homeassistant.core import HomeAssistant from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from . import DATA_KEY, VolvoEntity, VolvoUpdateCoordinator from . import VolvoEntity, VolvoUpdateCoordinator
from .const import DOMAIN, VOLVO_DISCOVERY_NEW
async def async_setup_platform( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
config: ConfigType, config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
) -> None: ) -> None:
"""Set up the Volvo sensors.""" """Configure binary_sensors from a config entry created in the integrations UI."""
if discovery_info is None: coordinator: VolvoUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id]
return volvo_data = coordinator.volvo_data
async_add_entities([VolvoSensor(hass.data[DATA_KEY], *discovery_info)])
@callback
def async_discover_device(instruments: list[Instrument]) -> None:
"""Discover and add a discovered Volvo On Call binary sensor."""
entities: list[VolvoSensor] = []
for instrument in instruments:
if instrument.component == "binary_sensor":
entities.append(
VolvoSensor(
coordinator,
instrument.vehicle.vin,
instrument.component,
instrument.attr,
instrument.slug_attr,
)
)
async_add_entities(entities)
async_discover_device([*volvo_data.instruments])
config_entry.async_on_unload(
async_dispatcher_connect(hass, VOLVO_DISCOVERY_NEW, async_discover_device)
)
class VolvoSensor(VolvoEntity, BinarySensorEntity): class VolvoSensor(VolvoEntity, BinarySensorEntity):

View File

@ -0,0 +1,86 @@
"""Config flow for Volvo On Call integration."""
from __future__ import annotations
import logging
from typing import Any
import voluptuous as vol
from volvooncall import Connection
from homeassistant import config_entries
from homeassistant.const import CONF_PASSWORD, CONF_REGION, CONF_USERNAME
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers.aiohttp_client import async_get_clientsession
import homeassistant.helpers.config_validation as cv
from . import VolvoData
from .const import CONF_MUTABLE, CONF_SCANDINAVIAN_MILES, DOMAIN
from .errors import InvalidAuth
_LOGGER = logging.getLogger(__name__)
USER_SCHEMA = vol.Schema(
{
vol.Required(CONF_USERNAME): cv.string,
vol.Required(CONF_PASSWORD): cv.string,
vol.Required(CONF_REGION, default=None): vol.In(
{"na": "North America", "cn": "China", None: "Rest of world"}
),
vol.Optional(CONF_MUTABLE, default=True): cv.boolean,
vol.Optional(CONF_SCANDINAVIAN_MILES, default=False): cv.boolean,
},
)
class VolvoOnCallConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""VolvoOnCall config flow."""
VERSION = 1
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle user step."""
errors = {}
if user_input is not None:
await self.async_set_unique_id(user_input[CONF_USERNAME])
self._abort_if_unique_id_configured()
try:
await self.is_valid(user_input)
except InvalidAuth:
errors["base"] = "invalid_auth"
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Unhandled exception in user step")
errors["base"] = "unknown"
if not errors:
return self.async_create_entry(
title=user_input[CONF_USERNAME], data=user_input
)
return self.async_show_form(
step_id="user", data_schema=USER_SCHEMA, errors=errors
)
async def async_step_import(self, import_data) -> FlowResult:
"""Import volvooncall config from configuration.yaml."""
return await self.async_step_user(import_data)
async def is_valid(self, user_input):
"""Check for user input errors."""
session = async_get_clientsession(self.hass)
region: str | None = user_input.get(CONF_REGION)
connection = Connection(
session=session,
username=user_input[CONF_USERNAME],
password=user_input[CONF_PASSWORD],
service_url=None,
region=region,
)
test_volvo_data = VolvoData(self.hass, connection, user_input)
await test_volvo_data.auth_is_valid()

View File

@ -0,0 +1,62 @@
"""Constants for volvooncall."""
from datetime import timedelta
DOMAIN = "volvooncall"
DEFAULT_UPDATE_INTERVAL = timedelta(minutes=1)
CONF_SERVICE_URL = "service_url"
CONF_SCANDINAVIAN_MILES = "scandinavian_miles"
CONF_MUTABLE = "mutable"
PLATFORMS = {
"sensor": "sensor",
"binary_sensor": "binary_sensor",
"lock": "lock",
"device_tracker": "device_tracker",
"switch": "switch",
}
RESOURCES = [
"position",
"lock",
"heater",
"odometer",
"trip_meter1",
"trip_meter2",
"average_speed",
"fuel_amount",
"fuel_amount_level",
"average_fuel_consumption",
"distance_to_empty",
"washer_fluid_level",
"brake_fluid",
"service_warning_status",
"bulb_failures",
"battery_range",
"battery_level",
"time_to_fully_charged",
"battery_charge_status",
"engine_start",
"last_trip",
"is_engine_running",
"doors_hood_open",
"doors_tailgate_open",
"doors_front_left_door_open",
"doors_front_right_door_open",
"doors_rear_left_door_open",
"doors_rear_right_door_open",
"windows_front_left_window_open",
"windows_front_right_window_open",
"windows_rear_left_window_open",
"windows_rear_right_window_open",
"tyre_pressure_front_left_tyre_pressure",
"tyre_pressure_front_right_tyre_pressure",
"tyre_pressure_rear_left_tyre_pressure",
"tyre_pressure_rear_right_tyre_pressure",
"any_door_open",
"any_window_open",
]
VOLVO_DISCOVERY_NEW = "volvo_discovery_new"

View File

@ -1,45 +1,80 @@
"""Support for tracking a Volvo.""" """Support for tracking a Volvo."""
from __future__ import annotations from __future__ import annotations
from homeassistant.components.device_tracker import AsyncSeeCallback, SourceType from volvooncall.dashboard import Instrument
from homeassistant.core import HomeAssistant
from homeassistant.components.device_tracker import SourceType
from homeassistant.components.device_tracker.config_entry import TrackerEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util import slugify
from . import DATA_KEY, SIGNAL_STATE_UPDATED, VolvoUpdateCoordinator from . import VolvoEntity, VolvoUpdateCoordinator
from .const import DOMAIN, VOLVO_DISCOVERY_NEW
async def async_setup_scanner( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
config: ConfigType, config_entry: ConfigEntry,
async_see: AsyncSeeCallback, async_add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None, ) -> None:
) -> bool: """Configure device_trackers from a config entry created in the integrations UI."""
"""Set up the Volvo tracker.""" coordinator: VolvoUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id]
if discovery_info is None:
return False
vin, component, attr, slug_attr = discovery_info
coordinator: VolvoUpdateCoordinator = hass.data[DATA_KEY]
volvo_data = coordinator.volvo_data volvo_data = coordinator.volvo_data
instrument = volvo_data.instrument(vin, component, attr, slug_attr)
if instrument is None: @callback
return False def async_discover_device(instruments: list[Instrument]) -> None:
"""Discover and add a discovered Volvo On Call device tracker."""
entities: list[VolvoTrackerEntity] = []
async def see_vehicle() -> None: for instrument in instruments:
"""Handle the reporting of the vehicle position.""" if instrument.component == "device_tracker":
host_name = instrument.vehicle_name entities.append(
dev_id = f"volvo_{slugify(host_name)}" VolvoTrackerEntity(
await async_see( instrument.vehicle.vin,
dev_id=dev_id, instrument.component,
host_name=host_name, instrument.attr,
source_type=SourceType.GPS, instrument.slug_attr,
gps=instrument.state, coordinator,
icon="mdi:car", )
)
async_add_entities(entities)
async_discover_device([*volvo_data.instruments])
config_entry.async_on_unload(
async_dispatcher_connect(hass, VOLVO_DISCOVERY_NEW, async_discover_device)
)
class VolvoTrackerEntity(VolvoEntity, TrackerEntity):
"""A tracked Volvo vehicle."""
@property
def latitude(self) -> float | None:
"""Return latitude value of the device."""
latitude, _ = self._get_pos()
return latitude
@property
def longitude(self) -> float | None:
"""Return longitude value of the device."""
_, longitude = self._get_pos()
return longitude
@property
def source_type(self) -> SourceType | str:
"""Return the source type (GPS)."""
return SourceType.GPS
def _get_pos(self) -> tuple[float, float]:
volvo_data = self.coordinator.volvo_data
instrument = volvo_data.instrument(
self.vin, self.component, self.attribute, self.slug_attr
) )
async_dispatcher_connect(hass, SIGNAL_STATE_UPDATED, see_vehicle) latitude, longitude, _, _, _ = instrument.state
return True return (float(latitude), float(longitude))

View File

@ -0,0 +1,6 @@
"""Exceptions specific to volvooncall."""
from homeassistant.exceptions import HomeAssistantError
class InvalidAuth(HomeAssistantError):
"""Error to indicate there is invalid auth."""

View File

@ -4,27 +4,51 @@ from __future__ import annotations
from typing import Any from typing import Any
from volvooncall.dashboard import Lock from volvooncall.dashboard import Instrument, Lock
from homeassistant.components.lock import LockEntity from homeassistant.components.lock import LockEntity
from homeassistant.core import HomeAssistant from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from . import DATA_KEY, VolvoEntity, VolvoUpdateCoordinator from . import VolvoEntity, VolvoUpdateCoordinator
from .const import DOMAIN, VOLVO_DISCOVERY_NEW
async def async_setup_platform( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
config: ConfigType, config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
) -> None: ) -> None:
"""Set up the Volvo On Call lock.""" """Configure locks from a config entry created in the integrations UI."""
if discovery_info is None: coordinator: VolvoUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id]
return volvo_data = coordinator.volvo_data
async_add_entities([VolvoLock(hass.data[DATA_KEY], *discovery_info)]) @callback
def async_discover_device(instruments: list[Instrument]) -> None:
"""Discover and add a discovered Volvo On Call lock."""
entities: list[VolvoLock] = []
for instrument in instruments:
if instrument.component == "lock":
entities.append(
VolvoLock(
coordinator,
instrument.vehicle.vin,
instrument.component,
instrument.attr,
instrument.slug_attr,
)
)
async_add_entities(entities)
async_discover_device([*volvo_data.instruments])
config_entry.async_on_unload(
async_dispatcher_connect(hass, VOLVO_DISCOVERY_NEW, async_discover_device)
)
class VolvoLock(VolvoEntity, LockEntity): class VolvoLock(VolvoEntity, LockEntity):

View File

@ -3,7 +3,9 @@
"name": "Volvo On Call", "name": "Volvo On Call",
"documentation": "https://www.home-assistant.io/integrations/volvooncall", "documentation": "https://www.home-assistant.io/integrations/volvooncall",
"requirements": ["volvooncall==0.10.0"], "requirements": ["volvooncall==0.10.0"],
"dependencies": ["repairs"],
"codeowners": ["@molobrakos"], "codeowners": ["@molobrakos"],
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"loggers": ["geopy", "hbmqtt", "volvooncall"] "loggers": ["geopy", "hbmqtt", "volvooncall"],
"config_flow": true
} }

View File

@ -1,24 +1,51 @@
"""Support for Volvo On Call sensors.""" """Support for Volvo On Call sensors."""
from __future__ import annotations from __future__ import annotations
from volvooncall.dashboard import Instrument
from homeassistant.components.sensor import SensorEntity from homeassistant.components.sensor import SensorEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from . import DATA_KEY, VolvoEntity, VolvoUpdateCoordinator from . import VolvoEntity, VolvoUpdateCoordinator
from .const import DOMAIN, VOLVO_DISCOVERY_NEW
async def async_setup_platform( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
config: ConfigType, config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
) -> None: ) -> None:
"""Set up the Volvo sensors.""" """Configure sensors from a config entry created in the integrations UI."""
if discovery_info is None: coordinator: VolvoUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id]
return volvo_data = coordinator.volvo_data
async_add_entities([VolvoSensor(hass.data[DATA_KEY], *discovery_info)])
@callback
def async_discover_device(instruments: list[Instrument]) -> None:
"""Discover and add a discovered Volvo On Call sensor."""
entities: list[VolvoSensor] = []
for instrument in instruments:
if instrument.component == "sensor":
entities.append(
VolvoSensor(
coordinator,
instrument.vehicle.vin,
instrument.component,
instrument.attr,
instrument.slug_attr,
)
)
async_add_entities(entities)
async_discover_device([*volvo_data.instruments])
config_entry.async_on_unload(
async_dispatcher_connect(hass, VOLVO_DISCOVERY_NEW, async_discover_device)
)
class VolvoSensor(VolvoEntity, SensorEntity): class VolvoSensor(VolvoEntity, SensorEntity):

View File

@ -0,0 +1,28 @@
{
"config": {
"step": {
"user": {
"data": {
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]",
"region": "Region",
"mutable": "Allow Remote Start / Lock / etc.",
"scandinavian_miles": "Use Scandinavian Miles"
}
}
},
"error": {
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"abort": {
"already_configured": "Account is already configured"
}
},
"issues": {
"deprecated_yaml": {
"title": "The Volvo On Call YAML configuration is being removed",
"description": "Configuring the Volvo On Call platform using YAML is being removed in a future release of Home Assistant.\n\nYour existing configuration has been imported into the UI automatically. Remove the YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue."
}
}
}

View File

@ -1,24 +1,51 @@
"""Support for Volvo heater.""" """Support for Volvo heater."""
from __future__ import annotations from __future__ import annotations
from volvooncall.dashboard import Instrument
from homeassistant.components.switch import SwitchEntity from homeassistant.components.switch import SwitchEntity
from homeassistant.core import HomeAssistant from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from . import DATA_KEY, VolvoEntity, VolvoUpdateCoordinator from . import VolvoEntity, VolvoUpdateCoordinator
from .const import DOMAIN, VOLVO_DISCOVERY_NEW
async def async_setup_platform( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
config: ConfigType, config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
) -> None: ) -> None:
"""Set up a Volvo switch.""" """Configure binary_sensors from a config entry created in the integrations UI."""
if discovery_info is None: coordinator: VolvoUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id]
return volvo_data = coordinator.volvo_data
async_add_entities([VolvoSwitch(hass.data[DATA_KEY], *discovery_info)])
@callback
def async_discover_device(instruments: list[Instrument]) -> None:
"""Discover and add a discovered Volvo On Call switch."""
entities: list[VolvoSwitch] = []
for instrument in instruments:
if instrument.component == "switch":
entities.append(
VolvoSwitch(
coordinator,
instrument.vehicle.vin,
instrument.component,
instrument.attr,
instrument.slug_attr,
)
)
async_add_entities(entities)
async_discover_device([*volvo_data.instruments])
config_entry.async_on_unload(
async_dispatcher_connect(hass, VOLVO_DISCOVERY_NEW, async_discover_device)
)
class VolvoSwitch(VolvoEntity, SwitchEntity): class VolvoSwitch(VolvoEntity, SwitchEntity):

View File

@ -0,0 +1,29 @@
{
"config": {
"step": {
"user": {
"title": "Login to Volvo On Call",
"data": {
"username": "Username",
"password": "Password",
"region": "Region",
"mutable": "Allow Remote Start / Lock / etc.",
"scandinavian_miles": "Use Scandinavian Miles"
}
}
},
"error": {
"invalid_auth": "Authentication failed. Please check your username, password, and region.",
"unknown": "Unknown error."
},
"abort": {
"already_configured": "Account is already configured"
}
},
"issues": {
"deprecated_yaml": {
"title": "The Volvo On Call YAML configuration is being removed",
"description": "Configuring the Volvo On Call platform using YAML is being removed in a future release of Home Assistant.\n\nYour existing configuration has been imported into the UI automatically. Remove the YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue."
}
}
}

View File

@ -415,6 +415,7 @@ FLOWS = {
"vizio", "vizio",
"vlc_telnet", "vlc_telnet",
"volumio", "volumio",
"volvooncall",
"vulcan", "vulcan",
"wallbox", "wallbox",
"watttime", "watttime",

View File

@ -1655,6 +1655,9 @@ venstarcolortouch==0.18
# homeassistant.components.vilfo # homeassistant.components.vilfo
vilfo-api-client==0.3.2 vilfo-api-client==0.3.2
# homeassistant.components.volvooncall
volvooncall==0.10.0
# homeassistant.components.verisure # homeassistant.components.verisure
vsure==1.8.1 vsure==1.8.1

View File

@ -0,0 +1 @@
"""Tests for the Volvo On Call integration."""

View File

@ -0,0 +1,169 @@
"""Test the Volvo On Call config flow."""
from unittest.mock import Mock, patch
from aiohttp import ClientResponseError
from homeassistant import config_entries
from homeassistant.components.volvooncall.const import DOMAIN
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from tests.common import MockConfigEntry
async def test_form(hass: HomeAssistant) -> None:
"""Test we get the form."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == FlowResultType.FORM
assert len(result["errors"]) == 0
with patch("volvooncall.Connection.get"), patch(
"homeassistant.components.volvooncall.async_setup_entry",
return_value=True,
) as mock_setup_entry:
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
"username": "test-username",
"password": "test-password",
"region": "na",
"mutable": True,
"scandinavian_miles": False,
},
)
await hass.async_block_till_done()
assert result2["type"] == FlowResultType.CREATE_ENTRY
assert result2["title"] == "test-username"
assert result2["data"] == {
"username": "test-username",
"password": "test-password",
"region": "na",
"mutable": True,
"scandinavian_miles": False,
}
assert len(mock_setup_entry.mock_calls) == 1
async def test_form_invalid_auth(hass: HomeAssistant) -> None:
"""Test we handle invalid auth."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
exc = ClientResponseError(Mock(), (), status=401)
with patch(
"volvooncall.Connection.get",
side_effect=exc,
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
"username": "test-username",
"password": "test-password",
"region": "na",
"mutable": True,
"scandinavian_miles": False,
},
)
assert result2["type"] == FlowResultType.FORM
assert result2["errors"] == {"base": "invalid_auth"}
async def test_flow_already_configured(hass: HomeAssistant) -> None:
"""Test we handle a flow that has already been configured."""
first_entry = MockConfigEntry(domain=DOMAIN, unique_id="test-username")
first_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == FlowResultType.FORM
assert len(result["errors"]) == 0
with patch("volvooncall.Connection.get"), patch(
"homeassistant.components.volvooncall.async_setup_entry",
return_value=True,
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
"username": "test-username",
"password": "test-password",
"region": "na",
"mutable": True,
"scandinavian_miles": False,
},
)
await hass.async_block_till_done()
assert result2["type"] == FlowResultType.ABORT
assert result2["reason"] == "already_configured"
async def test_form_other_exception(hass: HomeAssistant) -> None:
"""Test we handle other exceptions."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
with patch(
"volvooncall.Connection.get",
side_effect=Exception,
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
"username": "test-username",
"password": "test-password",
"region": "na",
"mutable": True,
"scandinavian_miles": False,
},
)
assert result2["type"] == FlowResultType.FORM
assert result2["errors"] == {"base": "unknown"}
async def test_import(hass: HomeAssistant) -> None:
"""Test a YAML import."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_IMPORT}
)
assert result["type"] == FlowResultType.FORM
assert len(result["errors"]) == 0
with patch("volvooncall.Connection.get"), patch(
"homeassistant.components.volvooncall.async_setup",
return_value=True,
), patch(
"homeassistant.components.volvooncall.async_setup_entry",
return_value=True,
) as mock_setup_entry:
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
"username": "test-username",
"password": "test-password",
"region": "na",
"mutable": True,
"scandinavian_miles": False,
},
)
await hass.async_block_till_done()
assert result2["type"] == FlowResultType.CREATE_ENTRY
assert result2["title"] == "test-username"
assert result2["data"] == {
"username": "test-username",
"password": "test-password",
"region": "na",
"mutable": True,
"scandinavian_miles": False,
}
assert len(mock_setup_entry.mock_calls) == 1