core/homeassistant/components/volvooncall/__init__.py

343 lines
10 KiB
Python

"""Support for Volvo On Call."""
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.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import (
CONF_NAME,
CONF_PASSWORD,
CONF_REGION,
CONF_RESOURCES,
CONF_SCAN_INTERVAL,
CONF_UNIT_SYSTEM,
CONF_USERNAME,
)
from homeassistant.core import HomeAssistant
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
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.typing import ConfigType
from homeassistant.helpers.update_coordinator import (
CoordinatorEntity,
DataUpdateCoordinator,
UpdateFailed,
)
from .const import (
CONF_MUTABLE,
CONF_SCANDINAVIAN_MILES,
CONF_SERVICE_URL,
DEFAULT_UPDATE_INTERVAL,
DOMAIN,
PLATFORMS,
RESOURCES,
UNIT_SYSTEM_IMPERIAL,
UNIT_SYSTEM_METRIC,
UNIT_SYSTEM_SCANDINAVIAN_MILES,
VOLVO_DISCOVERY_NEW,
)
from .errors import InvalidAuth
_LOGGER = logging.getLogger(__name__)
CONFIG_SCHEMA = 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)),
vol.Optional(CONF_NAME, default={}): cv.schema_with_slug_keys(
cv.string
),
vol.Optional(CONF_RESOURCES): vol.All(
cv.ensure_list, [vol.In(RESOURCES)]
),
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:
"""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."""
# added CONF_UNIT_SYSTEM / deprecated CONF_SCANDINAVIAN_MILES in 2022.10 to support imperial units
if CONF_UNIT_SYSTEM not in entry.data:
new_conf = {**entry.data}
scandinavian_miles: bool = entry.data[CONF_SCANDINAVIAN_MILES]
new_conf[CONF_UNIT_SYSTEM] = (
UNIT_SYSTEM_SCANDINAVIAN_MILES if scandinavian_miles else UNIT_SYSTEM_METRIC
)
hass.config_entries.async_update_entry(entry, data=new_conf)
session = async_get_clientsession(hass)
connection = Connection(
session=session,
username=entry.data[CONF_USERNAME],
password=entry.data[CONF_PASSWORD],
service_url=None,
region=entry.data[CONF_REGION],
)
hass.data.setdefault(DOMAIN, {})
volvo_data = VolvoData(hass, connection, entry)
coordinator = hass.data[DOMAIN][entry.entry_id] = VolvoUpdateCoordinator(
hass, volvo_data
)
await coordinator.async_config_entry_first_refresh()
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:
"""Hold component state."""
def __init__(
self,
hass: HomeAssistant,
connection: Connection,
entry: ConfigEntry,
) -> None:
"""Initialize the component state."""
self.hass = hass
self.vehicles: set[str] = set()
self.instruments: set[Instrument] = set()
self.config_entry = entry
self.connection = connection
def instrument(self, vin, component, attr, slug_attr):
"""Return corresponding instrument."""
return next(
instrument
for instrument in self.instruments
if instrument.vehicle.vin == vin
and instrument.component == component
and instrument.attr == attr
and instrument.slug_attr == slug_attr
)
def vehicle_name(self, vehicle):
"""Provide a friendly name for a vehicle."""
if vehicle.registration_number and vehicle.registration_number != "UNKNOWN":
return vehicle.registration_number
if vehicle.vin:
return vehicle.vin
return "Volvo"
def discover_vehicle(self, vehicle):
"""Load relevant platforms."""
self.vehicles.add(vehicle.vin)
dashboard = vehicle.dashboard(
mutable=self.config_entry.data[CONF_MUTABLE],
scandinavian_miles=(
self.config_entry.data[CONF_UNIT_SYSTEM]
== UNIT_SYSTEM_SCANDINAVIAN_MILES
),
usa_units=(
self.config_entry.data[CONF_UNIT_SYSTEM] == UNIT_SYSTEM_IMPERIAL
),
)
for instrument in (
instrument
for instrument in dashboard.instruments
if instrument.component in PLATFORMS
):
self.instruments.add(instrument)
async_dispatcher_send(self.hass, VOLVO_DISCOVERY_NEW, [instrument])
async def update(self):
"""Update status from the online service."""
try:
await self.connection.update(journal=True)
except ClientResponseError as ex:
if ex.status == 401:
raise ConfigEntryAuthFailed(ex) from ex
raise UpdateFailed(ex) from ex
for vehicle in self.connection.vehicles:
if vehicle.vin not in self.vehicles:
self.discover_vehicle(vehicle)
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[None]):
"""Volvo coordinator."""
def __init__(self, hass: HomeAssistant, volvo_data: VolvoData) -> None:
"""Initialize the data update coordinator."""
super().__init__(
hass,
_LOGGER,
name="volvooncall",
update_interval=DEFAULT_UPDATE_INTERVAL,
)
self.volvo_data = volvo_data
async def _async_update_data(self) -> None:
"""Fetch data from API endpoint."""
async with async_timeout.timeout(10):
await self.volvo_data.update()
class VolvoEntity(CoordinatorEntity[VolvoUpdateCoordinator]):
"""Base class for all VOC entities."""
def __init__(
self,
vin: str,
component: str,
attribute: str,
slug_attr: str,
coordinator: VolvoUpdateCoordinator,
) -> None:
"""Initialize the entity."""
super().__init__(coordinator)
self.vin = vin
self.component = component
self.attribute = attribute
self.slug_attr = slug_attr
@property
def instrument(self):
"""Return corresponding instrument."""
return self.coordinator.volvo_data.instrument(
self.vin, self.component, self.attribute, self.slug_attr
)
@property
def icon(self):
"""Return the icon."""
return self.instrument.icon
@property
def vehicle(self):
"""Return vehicle."""
return self.instrument.vehicle
@property
def _entity_name(self):
return self.instrument.name
@property
def _vehicle_name(self):
return self.coordinator.volvo_data.vehicle_name(self.vehicle)
@property
def name(self):
"""Return full name of the entity."""
return f"{self._vehicle_name} {self._entity_name}"
@property
def assumed_state(self):
"""Return true if unable to access real state of entity."""
return True
@property
def device_info(self) -> DeviceInfo:
"""Return a inique set of attributes for each vehicle."""
return DeviceInfo(
identifiers={(DOMAIN, self.vehicle.vin)},
name=self._vehicle_name,
model=self.vehicle.vehicle_type,
manufacturer="Volvo",
)
@property
def extra_state_attributes(self):
"""Return device specific state attributes."""
return dict(
self.instrument.attributes,
model=f"{self.vehicle.vehicle_type}/{self.vehicle.model_year}",
)
@property
def unique_id(self) -> str:
"""Return a unique ID."""
slug_override = ""
if self.instrument.slug_override is not None:
slug_override = f"-{self.instrument.slug_override}"
return f"{self.vin}-{self.component}-{self.attribute}{slug_override}"