diff --git a/.coveragerc b/.coveragerc index 693083081f4..942f32eb9a5 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1427,7 +1427,14 @@ omit = homeassistant/components/volumio/__init__.py homeassistant/components/volumio/browse_media.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/calendar.py homeassistant/components/vulcan/fetch_data.py diff --git a/CODEOWNERS b/CODEOWNERS index 2513e290230..799c81bfa81 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1201,6 +1201,7 @@ build.json @home-assistant/supervisor /homeassistant/components/volumio/ @OnFreund /tests/components/volumio/ @OnFreund /homeassistant/components/volvooncall/ @molobrakos +/tests/components/volvooncall/ @molobrakos /homeassistant/components/vulcan/ @Antoni-Czaplicki /tests/components/vulcan/ @Antoni-Czaplicki /homeassistant/components/wake_on_lan/ @ntilley905 diff --git a/homeassistant/components/volvooncall/__init__.py b/homeassistant/components/volvooncall/__init__.py index cf385d320ca..52890ce9d55 100644 --- a/homeassistant/components/volvooncall/__init__.py +++ b/homeassistant/components/volvooncall/__init__.py @@ -1,12 +1,15 @@ """Support for Volvo On Call.""" -from datetime import timedelta + import logging +from aiohttp.client_exceptions import ClientResponseError import async_timeout import voluptuous as vol from volvooncall import Connection 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 ( CONF_NAME, CONF_PASSWORD, @@ -16,7 +19,7 @@ from homeassistant.const import ( CONF_USERNAME, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import discovery +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_send @@ -27,121 +30,119 @@ from homeassistant.helpers.update_coordinator import ( UpdateFailed, ) -DOMAIN = "volvooncall" - -DATA_KEY = DOMAIN +from .const import ( + CONF_MUTABLE, + CONF_SCANDINAVIAN_MILES, + CONF_SERVICE_URL, + DEFAULT_UPDATE_INTERVAL, + DOMAIN, + PLATFORMS, + RESOURCES, + VOLVO_DISCOVERY_NEW, +) +from .errors import InvalidAuth _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( - { - DOMAIN: vol.All( - cv.deprecated(CONF_SCAN_INTERVAL), - cv.deprecated(CONF_NAME), - cv.deprecated(CONF_RESOURCES), - vol.Schema( + vol.All( + cv.deprecated(DOMAIN), + { + DOMAIN: vol.Schema( { vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string, vol.Optional( CONF_SCAN_INTERVAL, default=DEFAULT_UPDATE_INTERVAL - ): vol.All( - cv.time_period, vol.Clamp(min=DEFAULT_UPDATE_INTERVAL) - ), # ignored, using DataUpdateCoordinator instead + ): vol.All(cv.time_period, vol.Clamp(min=DEFAULT_UPDATE_INTERVAL)), vol.Optional(CONF_NAME, default={}): cv.schema_with_slug_keys( cv.string - ), # ignored, users can modify names of entities in the UI + ), vol.Optional(CONF_RESOURCES): vol.All( cv.ensure_list, [vol.In(RESOURCES)] - ), # ignored, users can disable entities in the UI + ), vol.Optional(CONF_REGION): cv.string, vol.Optional(CONF_SERVICE_URL): cv.string, vol.Optional(CONF_MUTABLE, default=True): cv.boolean, vol.Optional(CONF_SCANDINAVIAN_MILES, default=False): cv.boolean, } - ), - ) - }, + ) + }, + ), extra=vol.ALLOW_EXTRA, ) 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) connection = Connection( session=session, - username=config[DOMAIN].get(CONF_USERNAME), - password=config[DOMAIN].get(CONF_PASSWORD), - service_url=config[DOMAIN].get(CONF_SERVICE_URL), - region=config[DOMAIN].get(CONF_REGION), + username=entry.data[CONF_USERNAME], + password=entry.data[CONF_PASSWORD], + service_url=None, + 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: @@ -151,13 +152,13 @@ class VolvoData: self, hass: HomeAssistant, connection: Connection, - config: ConfigType, + entry: ConfigEntry, ) -> None: """Initialize the component state.""" self.hass = hass self.vehicles: set[str] = set() self.instruments: set[Instrument] = set() - self.config = config + self.config_entry = entry self.connection = connection def instrument(self, vin, component, attr, slug_attr): @@ -184,8 +185,8 @@ class VolvoData: self.vehicles.add(vehicle.vin) dashboard = vehicle.dashboard( - mutable=self.config[DOMAIN][CONF_MUTABLE], - scandinavian_miles=self.config[DOMAIN][CONF_SCANDINAVIAN_MILES], + mutable=self.config_entry.data[CONF_MUTABLE], + scandinavian_miles=self.config_entry.data[CONF_SCANDINAVIAN_MILES], ) for instrument in ( @@ -193,23 +194,8 @@ class VolvoData: for instrument in dashboard.instruments if instrument.component in PLATFORMS ): - self.instruments.add(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_dispatcher_send(self.hass, VOLVO_DISCOVERY_NEW, [instrument]) async def update(self): """Update status from the online service.""" @@ -220,11 +206,15 @@ class VolvoData: if vehicle.vin not in self.vehicles: 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 + 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): """Volvo coordinator.""" diff --git a/homeassistant/components/volvooncall/binary_sensor.py b/homeassistant/components/volvooncall/binary_sensor.py index 2aeaeff93e4..77e1b7183db 100644 --- a/homeassistant/components/volvooncall/binary_sensor.py +++ b/homeassistant/components/volvooncall/binary_sensor.py @@ -4,28 +4,54 @@ from __future__ import annotations from contextlib import suppress import voluptuous as vol +from volvooncall.dashboard import Instrument from homeassistant.components.binary_sensor import ( DEVICE_CLASSES_SCHEMA, 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.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, - config: ConfigType, + config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Set up the Volvo sensors.""" - if discovery_info is None: - return - async_add_entities([VolvoSensor(hass.data[DATA_KEY], *discovery_info)]) + """Configure binary_sensors from a config entry created in the integrations UI.""" + coordinator: VolvoUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + volvo_data = coordinator.volvo_data + + @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): diff --git a/homeassistant/components/volvooncall/config_flow.py b/homeassistant/components/volvooncall/config_flow.py new file mode 100644 index 00000000000..5a0756b5725 --- /dev/null +++ b/homeassistant/components/volvooncall/config_flow.py @@ -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() diff --git a/homeassistant/components/volvooncall/const.py b/homeassistant/components/volvooncall/const.py new file mode 100644 index 00000000000..bc72f9c5267 --- /dev/null +++ b/homeassistant/components/volvooncall/const.py @@ -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" diff --git a/homeassistant/components/volvooncall/device_tracker.py b/homeassistant/components/volvooncall/device_tracker.py index ffed8005f36..159cb39cf6a 100644 --- a/homeassistant/components/volvooncall/device_tracker.py +++ b/homeassistant/components/volvooncall/device_tracker.py @@ -1,45 +1,80 @@ """Support for tracking a Volvo.""" from __future__ import annotations -from homeassistant.components.device_tracker import AsyncSeeCallback, SourceType -from homeassistant.core import HomeAssistant +from volvooncall.dashboard import Instrument + +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.typing import ConfigType, DiscoveryInfoType -from homeassistant.util import slugify +from homeassistant.helpers.entity_platform import AddEntitiesCallback -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, - config: ConfigType, - async_see: AsyncSeeCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> bool: - """Set up the Volvo tracker.""" - if discovery_info is None: - return False - - vin, component, attr, slug_attr = discovery_info - coordinator: VolvoUpdateCoordinator = hass.data[DATA_KEY] + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Configure device_trackers from a config entry created in the integrations UI.""" + coordinator: VolvoUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] volvo_data = coordinator.volvo_data - instrument = volvo_data.instrument(vin, component, attr, slug_attr) - if instrument is None: - return False + @callback + 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: - """Handle the reporting of the vehicle position.""" - host_name = instrument.vehicle_name - dev_id = f"volvo_{slugify(host_name)}" - await async_see( - dev_id=dev_id, - host_name=host_name, - source_type=SourceType.GPS, - gps=instrument.state, - icon="mdi:car", + for instrument in instruments: + if instrument.component == "device_tracker": + entities.append( + VolvoTrackerEntity( + instrument.vehicle.vin, + instrument.component, + instrument.attr, + instrument.slug_attr, + coordinator, + ) + ) + + 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)) diff --git a/homeassistant/components/volvooncall/errors.py b/homeassistant/components/volvooncall/errors.py new file mode 100644 index 00000000000..a3af1125b48 --- /dev/null +++ b/homeassistant/components/volvooncall/errors.py @@ -0,0 +1,6 @@ +"""Exceptions specific to volvooncall.""" +from homeassistant.exceptions import HomeAssistantError + + +class InvalidAuth(HomeAssistantError): + """Error to indicate there is invalid auth.""" diff --git a/homeassistant/components/volvooncall/lock.py b/homeassistant/components/volvooncall/lock.py index da36ca2e573..a48b5dc6b65 100644 --- a/homeassistant/components/volvooncall/lock.py +++ b/homeassistant/components/volvooncall/lock.py @@ -4,27 +4,51 @@ from __future__ import annotations from typing import Any -from volvooncall.dashboard import Lock +from volvooncall.dashboard import Instrument, Lock 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.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, - config: ConfigType, + config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Set up the Volvo On Call lock.""" - if discovery_info is None: - return + """Configure locks from a config entry created in the integrations UI.""" + coordinator: VolvoUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + 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): diff --git a/homeassistant/components/volvooncall/manifest.json b/homeassistant/components/volvooncall/manifest.json index fe7e384f72a..16628d0a5d2 100644 --- a/homeassistant/components/volvooncall/manifest.json +++ b/homeassistant/components/volvooncall/manifest.json @@ -3,7 +3,9 @@ "name": "Volvo On Call", "documentation": "https://www.home-assistant.io/integrations/volvooncall", "requirements": ["volvooncall==0.10.0"], + "dependencies": ["repairs"], "codeowners": ["@molobrakos"], "iot_class": "cloud_polling", - "loggers": ["geopy", "hbmqtt", "volvooncall"] + "loggers": ["geopy", "hbmqtt", "volvooncall"], + "config_flow": true } diff --git a/homeassistant/components/volvooncall/sensor.py b/homeassistant/components/volvooncall/sensor.py index 41426ff878a..0f4269732e3 100644 --- a/homeassistant/components/volvooncall/sensor.py +++ b/homeassistant/components/volvooncall/sensor.py @@ -1,24 +1,51 @@ """Support for Volvo On Call sensors.""" from __future__ import annotations +from volvooncall.dashboard import Instrument + from homeassistant.components.sensor import SensorEntity +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.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, - config: ConfigType, + config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Set up the Volvo sensors.""" - if discovery_info is None: - return - async_add_entities([VolvoSensor(hass.data[DATA_KEY], *discovery_info)]) + """Configure sensors from a config entry created in the integrations UI.""" + coordinator: VolvoUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + volvo_data = coordinator.volvo_data + + @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): diff --git a/homeassistant/components/volvooncall/strings.json b/homeassistant/components/volvooncall/strings.json new file mode 100644 index 00000000000..1982b3c353c --- /dev/null +++ b/homeassistant/components/volvooncall/strings.json @@ -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." + } + } +} diff --git a/homeassistant/components/volvooncall/switch.py b/homeassistant/components/volvooncall/switch.py index 6c8519f12e8..4d7b65bce95 100644 --- a/homeassistant/components/volvooncall/switch.py +++ b/homeassistant/components/volvooncall/switch.py @@ -1,24 +1,51 @@ """Support for Volvo heater.""" from __future__ import annotations +from volvooncall.dashboard import Instrument + 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.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, - config: ConfigType, + config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Set up a Volvo switch.""" - if discovery_info is None: - return - async_add_entities([VolvoSwitch(hass.data[DATA_KEY], *discovery_info)]) + """Configure binary_sensors from a config entry created in the integrations UI.""" + coordinator: VolvoUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + volvo_data = coordinator.volvo_data + + @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): diff --git a/homeassistant/components/volvooncall/translations/en.json b/homeassistant/components/volvooncall/translations/en.json new file mode 100644 index 00000000000..b3468cb78e2 --- /dev/null +++ b/homeassistant/components/volvooncall/translations/en.json @@ -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." + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index c604c5e95cd..59070af31c7 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -415,6 +415,7 @@ FLOWS = { "vizio", "vlc_telnet", "volumio", + "volvooncall", "vulcan", "wallbox", "watttime", diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a558bbe5b28..99caab39cf7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1655,6 +1655,9 @@ venstarcolortouch==0.18 # homeassistant.components.vilfo vilfo-api-client==0.3.2 +# homeassistant.components.volvooncall +volvooncall==0.10.0 + # homeassistant.components.verisure vsure==1.8.1 diff --git a/tests/components/volvooncall/__init__.py b/tests/components/volvooncall/__init__.py new file mode 100644 index 00000000000..b49a0974c59 --- /dev/null +++ b/tests/components/volvooncall/__init__.py @@ -0,0 +1 @@ +"""Tests for the Volvo On Call integration.""" diff --git a/tests/components/volvooncall/test_config_flow.py b/tests/components/volvooncall/test_config_flow.py new file mode 100644 index 00000000000..3f64b052824 --- /dev/null +++ b/tests/components/volvooncall/test_config_flow.py @@ -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