diff --git a/.coveragerc b/.coveragerc index fc7a4691ef2..70d8f867e0e 100644 --- a/.coveragerc +++ b/.coveragerc @@ -688,7 +688,14 @@ omit = homeassistant/components/telnet/switch.py homeassistant/components/temper/sensor.py homeassistant/components/tensorflow/image_processing.py - homeassistant/components/tesla/* + homeassistant/components/tesla/__init__.py + homeassistant/components/tesla/binary_sensor.py + homeassistant/components/tesla/climate.py + homeassistant/components/tesla/const.py + homeassistant/components/tesla/device_tracker.py + homeassistant/components/tesla/lock.py + homeassistant/components/tesla/sensor.py + homeassistant/components/tesla/switch.py homeassistant/components/tfiac/climate.py homeassistant/components/thermoworks_smoke/sensor.py homeassistant/components/thethingsnetwork/* diff --git a/CODEOWNERS b/CODEOWNERS index 4fbdca20686..a7d1d346c5f 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -322,7 +322,7 @@ homeassistant/components/tahoma/* @philklei homeassistant/components/tautulli/* @ludeeus homeassistant/components/tellduslive/* @fredrike homeassistant/components/template/* @PhracturedBlue -homeassistant/components/tesla/* @zabuldon +homeassistant/components/tesla/* @zabuldon @alandtse homeassistant/components/tfiac/* @fredrike @mellado homeassistant/components/thethingsnetwork/* @fabaff homeassistant/components/threshold/* @fabaff diff --git a/homeassistant/components/tesla/.translations/en.json b/homeassistant/components/tesla/.translations/en.json new file mode 100644 index 00000000000..831406a0d63 --- /dev/null +++ b/homeassistant/components/tesla/.translations/en.json @@ -0,0 +1,30 @@ +{ + "config": { + "error": { + "connection_error": "Error connecting; check network and retry", + "identifier_exists": "Email already registered", + "invalid_credentials": "Invalid credentials", + "unknown_error": "Unknown error, please report log info" + }, + "step": { + "user": { + "data": { + "username": "Email Address", + "password": "Password" + }, + "description": "Please enter your information.", + "title": "Tesla - Configuration" + } + }, + "title": "Tesla" + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "Seconds between scans" + } + } + } + } +} diff --git a/homeassistant/components/tesla/__init__.py b/homeassistant/components/tesla/__init__.py index a3d45eed01c..dbfe07271ee 100644 --- a/homeassistant/components/tesla/__init__.py +++ b/homeassistant/components/tesla/__init__.py @@ -1,21 +1,32 @@ """Support for Tesla cars.""" +import asyncio from collections import defaultdict import logging -from teslajsonpy import Controller as teslaAPI, TeslaException +from teslajsonpy import Controller as TeslaAPI, TeslaException import voluptuous as vol +from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import ( ATTR_BATTERY_LEVEL, + CONF_ACCESS_TOKEN, CONF_PASSWORD, CONF_SCAN_INTERVAL, + CONF_TOKEN, CONF_USERNAME, ) -from homeassistant.helpers import aiohttp_client, config_validation as cv, discovery +from homeassistant.core import callback +from homeassistant.helpers import aiohttp_client, config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.util import slugify -from .const import DOMAIN, TESLA_COMPONENTS +from .config_flow import ( + CannotConnect, + InvalidAuth, + configured_instances, + validate_input, +) +from .const import DATA_LISTENER, DOMAIN, TESLA_COMPONENTS _LOGGER = logging.getLogger(__name__) @@ -34,69 +45,144 @@ CONFIG_SCHEMA = vol.Schema( extra=vol.ALLOW_EXTRA, ) -NOTIFICATION_ID = "tesla_integration_notification" -NOTIFICATION_TITLE = "Tesla integration setup" + +@callback +def _async_save_tokens(hass, config_entry, access_token, refresh_token): + hass.config_entries.async_update_entry( + config_entry, + data={ + **config_entry.data, + CONF_ACCESS_TOKEN: access_token, + CONF_TOKEN: refresh_token, + }, + ) async def async_setup(hass, base_config): """Set up of Tesla component.""" - config = base_config.get(DOMAIN) - email = config.get(CONF_USERNAME) - password = config.get(CONF_PASSWORD) - update_interval = config.get(CONF_SCAN_INTERVAL) - if hass.data.get(DOMAIN) is None: + def _update_entry(email, data=None, options=None): + data = data or {} + options = options or {CONF_SCAN_INTERVAL: 300} + for entry in hass.config_entries.async_entries(DOMAIN): + if email != entry.title: + continue + hass.config_entries.async_update_entry(entry, data=data, options=options) + + config = base_config.get(DOMAIN) + if not config: + return True + email = config[CONF_USERNAME] + password = config[CONF_PASSWORD] + scan_interval = config[CONF_SCAN_INTERVAL] + if email in configured_instances(hass): try: - websession = aiohttp_client.async_get_clientsession(hass) - controller = teslaAPI( - websession, - email=email, - password=password, - update_interval=update_interval, - ) - await controller.connect(test_login=False) - hass.data[DOMAIN] = {"controller": controller, "devices": defaultdict(list)} - _LOGGER.debug("Connected to the Tesla API.") - except TeslaException as ex: - if ex.code == 401: - hass.components.persistent_notification.create( - "Error:
Please check username and password." - "You will need to restart Home Assistant after fixing.", - title=NOTIFICATION_TITLE, - notification_id=NOTIFICATION_ID, - ) - else: - hass.components.persistent_notification.create( - "Error:
Can't communicate with Tesla API.
" - "Error code: {} Reason: {}" - "You will need to restart Home Assistant after fixing." - "".format(ex.code, ex.message), - title=NOTIFICATION_TITLE, - notification_id=NOTIFICATION_ID, - ) - _LOGGER.error("Unable to communicate with Tesla API: %s", ex.message) + info = await validate_input(hass, config) + except (CannotConnect, InvalidAuth): return False - all_devices = controller.get_homeassistant_components() + _update_entry( + email, + data={ + CONF_ACCESS_TOKEN: info[CONF_ACCESS_TOKEN], + CONF_TOKEN: info[CONF_TOKEN], + }, + options={CONF_SCAN_INTERVAL: scan_interval}, + ) + else: + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={CONF_USERNAME: email, CONF_PASSWORD: password}, + ) + ) + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][email] = {CONF_SCAN_INTERVAL: scan_interval} + return True + + +async def async_setup_entry(hass, config_entry): + """Set up Tesla as config entry.""" + + hass.data.setdefault(DOMAIN, {}) + config = config_entry.data + websession = aiohttp_client.async_get_clientsession(hass) + email = config_entry.title + if email in hass.data[DOMAIN] and CONF_SCAN_INTERVAL in hass.data[DOMAIN][email]: + scan_interval = hass.data[DOMAIN][email][CONF_SCAN_INTERVAL] + hass.config_entries.async_update_entry( + config_entry, options={CONF_SCAN_INTERVAL: scan_interval} + ) + hass.data[DOMAIN].pop(email) + try: + controller = TeslaAPI( + websession, + refresh_token=config[CONF_TOKEN], + update_interval=config_entry.options.get(CONF_SCAN_INTERVAL, 300), + ) + (refresh_token, access_token) = await controller.connect() + except TeslaException as ex: + _LOGGER.error("Unable to communicate with Tesla API: %s", ex.message) + return False + _async_save_tokens(hass, config_entry, access_token, refresh_token) + entry_data = hass.data[DOMAIN][config_entry.entry_id] = { + "controller": controller, + "devices": defaultdict(list), + DATA_LISTENER: [config_entry.add_update_listener(update_listener)], + } + _LOGGER.debug("Connected to the Tesla API.") + all_devices = entry_data["controller"].get_homeassistant_components() + if not all_devices: return False for device in all_devices: - hass.data[DOMAIN]["devices"][device.hass_type].append(device) + entry_data["devices"][device.hass_type].append(device) for component in TESLA_COMPONENTS: + _LOGGER.debug("Loading %s", component) hass.async_create_task( - discovery.async_load_platform(hass, component, DOMAIN, {}, base_config) + hass.config_entries.async_forward_entry_setup(config_entry, component) ) return True +async def async_unload_entry(hass, config_entry) -> bool: + """Unload a config entry.""" + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(config_entry, component) + for component in TESLA_COMPONENTS + ] + ) + for listener in hass.data[DOMAIN][config_entry.entry_id][DATA_LISTENER]: + listener() + username = config_entry.title + hass.data[DOMAIN].pop(config_entry.entry_id) + _LOGGER.debug("Unloaded entry for %s", username) + return True + + +async def update_listener(hass, config_entry): + """Update when config_entry options update.""" + controller = hass.data[DOMAIN][config_entry.entry_id]["controller"] + old_update_interval = controller.update_interval + controller.update_interval = config_entry.options.get(CONF_SCAN_INTERVAL) + _LOGGER.debug( + "Changing scan_interval from %s to %s", + old_update_interval, + controller.update_interval, + ) + + class TeslaDevice(Entity): """Representation of a Tesla device.""" - def __init__(self, tesla_device, controller): + def __init__(self, tesla_device, controller, config_entry): """Initialise the Tesla device.""" self.tesla_device = tesla_device self.controller = controller + self.config_entry = config_entry self._name = self.tesla_device.name self.tesla_id = slugify(self.tesla_device.uniq_name) self._attributes = {} @@ -124,6 +210,17 @@ class TeslaDevice(Entity): attr[ATTR_BATTERY_LEVEL] = self.tesla_device.battery_level() return attr + @property + def device_info(self): + """Return the device_info of the device.""" + return { + "identifiers": {(DOMAIN, self.tesla_device.id())}, + "name": self.tesla_device.car_name(), + "manufacturer": "Tesla", + "model": self.tesla_device.car_type, + "sw_version": self.tesla_device.car_version, + } + async def async_added_to_hass(self): """Register state update callback.""" pass @@ -134,4 +231,10 @@ class TeslaDevice(Entity): async def async_update(self): """Update the state of the device.""" + if self.controller.is_token_refreshed(): + (refresh_token, access_token) = self.controller.get_tokens() + _async_save_tokens( + self.hass, self.config_entry, access_token, refresh_token + ) + _LOGGER.debug("Saving new tokens in config_entry") await self.tesla_device.async_update() diff --git a/homeassistant/components/tesla/binary_sensor.py b/homeassistant/components/tesla/binary_sensor.py index 738533a9b56..8f610d960b3 100644 --- a/homeassistant/components/tesla/binary_sensor.py +++ b/homeassistant/components/tesla/binary_sensor.py @@ -8,21 +8,35 @@ from . import DOMAIN as TESLA_DOMAIN, TeslaDevice _LOGGER = logging.getLogger(__name__) -async def async_setup_platform(hass, config, add_entities, discovery_info=None): +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the Tesla binary sensor.""" - devices = [ - TeslaBinarySensor(device, hass.data[TESLA_DOMAIN]["controller"], "connectivity") - for device in hass.data[TESLA_DOMAIN]["devices"]["binary_sensor"] - ] - add_entities(devices, True) + pass + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the Tesla binary_sensors by config_entry.""" + async_add_entities( + [ + TeslaBinarySensor( + device, + hass.data[TESLA_DOMAIN][config_entry.entry_id]["controller"], + "connectivity", + config_entry, + ) + for device in hass.data[TESLA_DOMAIN][config_entry.entry_id]["devices"][ + "binary_sensor" + ] + ], + True, + ) class TeslaBinarySensor(TeslaDevice, BinarySensorDevice): """Implement an Tesla binary sensor for parking and charger.""" - def __init__(self, tesla_device, controller, sensor_type): + def __init__(self, tesla_device, controller, sensor_type, config_entry): """Initialise of a Tesla binary sensor.""" - super().__init__(tesla_device, controller) + super().__init__(tesla_device, controller, config_entry) self._state = False self._sensor_type = sensor_type diff --git a/homeassistant/components/tesla/climate.py b/homeassistant/components/tesla/climate.py index 85fd8a8e258..d3c87035c9c 100644 --- a/homeassistant/components/tesla/climate.py +++ b/homeassistant/components/tesla/climate.py @@ -16,21 +16,34 @@ _LOGGER = logging.getLogger(__name__) SUPPORT_HVAC = [HVAC_MODE_HEAT, HVAC_MODE_OFF] -async def async_setup_platform(hass, config, add_entities, discovery_info=None): +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the Tesla climate platform.""" - devices = [ - TeslaThermostat(device, hass.data[TESLA_DOMAIN]["controller"]) - for device in hass.data[TESLA_DOMAIN]["devices"]["climate"] - ] - add_entities(devices, True) + pass + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the Tesla binary_sensors by config_entry.""" + async_add_entities( + [ + TeslaThermostat( + device, + hass.data[TESLA_DOMAIN][config_entry.entry_id]["controller"], + config_entry, + ) + for device in hass.data[TESLA_DOMAIN][config_entry.entry_id]["devices"][ + "climate" + ] + ], + True, + ) class TeslaThermostat(TeslaDevice, ClimateDevice): """Representation of a Tesla climate.""" - def __init__(self, tesla_device, controller): + def __init__(self, tesla_device, controller, config_entry): """Initialize the Tesla device.""" - super().__init__(tesla_device, controller) + super().__init__(tesla_device, controller, config_entry) self._target_temperature = None self._temperature = None diff --git a/homeassistant/components/tesla/config_flow.py b/homeassistant/components/tesla/config_flow.py new file mode 100644 index 00000000000..2d2bc0158d2 --- /dev/null +++ b/homeassistant/components/tesla/config_flow.py @@ -0,0 +1,143 @@ +"""Tesla Config Flow.""" +import logging + +from teslajsonpy import Controller as TeslaAPI, TeslaException +import voluptuous as vol + +from homeassistant import config_entries, core, exceptions +from homeassistant.const import ( + CONF_ACCESS_TOKEN, + CONF_PASSWORD, + CONF_SCAN_INTERVAL, + CONF_TOKEN, + CONF_USERNAME, +) +from homeassistant.core import callback +from homeassistant.helpers import aiohttp_client, config_validation as cv + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +DATA_SCHEMA = vol.Schema( + {vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str} +) + + +@callback +def configured_instances(hass): + """Return a set of configured Tesla instances.""" + return set(entry.title for entry in hass.config_entries.async_entries(DOMAIN)) + + +class TeslaConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Tesla.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + async def async_step_import(self, import_config): + """Import a config entry from configuration.yaml.""" + return await self.async_step_user(import_config) + + async def async_step_user(self, user_input=None): + """Handle the start of the config flow.""" + + if not user_input: + return self.async_show_form( + step_id="user", + data_schema=DATA_SCHEMA, + errors={}, + description_placeholders={}, + ) + + if user_input[CONF_USERNAME] in configured_instances(self.hass): + return self.async_show_form( + step_id="user", + data_schema=DATA_SCHEMA, + errors={CONF_USERNAME: "identifier_exists"}, + description_placeholders={}, + ) + + try: + info = await validate_input(self.hass, user_input) + except CannotConnect: + return self.async_show_form( + step_id="user", + data_schema=DATA_SCHEMA, + errors={"base": "connection_error"}, + description_placeholders={}, + ) + except InvalidAuth: + return self.async_show_form( + step_id="user", + data_schema=DATA_SCHEMA, + errors={"base": "invalid_credentials"}, + description_placeholders={}, + ) + return self.async_create_entry(title=user_input[CONF_USERNAME], data=info) + + @staticmethod + @callback + def async_get_options_flow(config_entry): + """Get the options flow for this handler.""" + return OptionsFlowHandler(config_entry) + + +class OptionsFlowHandler(config_entries.OptionsFlow): + """Handle a option flow for Tesla.""" + + def __init__(self, config_entry: config_entries.ConfigEntry): + """Initialize options flow.""" + self.config_entry = config_entry + + async def async_step_init(self, user_input=None): + """Handle options flow.""" + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + data_schema = vol.Schema( + { + vol.Optional( + CONF_SCAN_INTERVAL, + default=self.config_entry.options.get(CONF_SCAN_INTERVAL, 300), + ): vol.All(cv.positive_int, vol.Clamp(min=300)) + } + ) + return self.async_show_form(step_id="init", data_schema=data_schema) + + +async def validate_input(hass: core.HomeAssistant, data): + """Validate the user input allows us to connect. + + Data has the keys from DATA_SCHEMA with values provided by the user. + """ + + config = {} + websession = aiohttp_client.async_get_clientsession(hass) + try: + controller = TeslaAPI( + websession, + email=data[CONF_USERNAME], + password=data[CONF_PASSWORD], + update_interval=300, + ) + (config[CONF_TOKEN], config[CONF_ACCESS_TOKEN]) = await controller.connect( + test_login=True + ) + except TeslaException as ex: + if ex.code == 401: + _LOGGER.error("Invalid credentials: %s", ex) + raise InvalidAuth() + _LOGGER.error("Unable to communicate with Tesla API: %s", ex) + raise CannotConnect() + _LOGGER.debug("Credentials successfully connected to the Tesla API") + return config + + +class CannotConnect(exceptions.HomeAssistantError): + """Error to indicate we cannot connect.""" + + +class InvalidAuth(exceptions.HomeAssistantError): + """Error to indicate there is invalid auth.""" diff --git a/homeassistant/components/tesla/device_tracker.py b/homeassistant/components/tesla/device_tracker.py index c205cc587eb..08e5d58ba6e 100644 --- a/homeassistant/components/tesla/device_tracker.py +++ b/homeassistant/components/tesla/device_tracker.py @@ -1,45 +1,70 @@ """Support for tracking Tesla cars.""" import logging -from homeassistant.helpers.event import async_track_utc_time_change -from homeassistant.util import slugify +from homeassistant.components.device_tracker import SOURCE_TYPE_GPS +from homeassistant.components.device_tracker.config_entry import TrackerEntity -from . import DOMAIN as TESLA_DOMAIN +from . import DOMAIN as TESLA_DOMAIN, TeslaDevice _LOGGER = logging.getLogger(__name__) -async def async_setup_scanner(hass, config, async_see, discovery_info=None): - """Set up the Tesla tracker.""" - tracker = TeslaDeviceTracker( - hass, config, async_see, hass.data[TESLA_DOMAIN]["devices"]["devices_tracker"] - ) - await tracker.update_info() - async_track_utc_time_change(hass, tracker.update_info, second=range(0, 60, 30)) - return True +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the Tesla binary_sensors by config_entry.""" + entities = [ + TeslaDeviceEntity( + device, + hass.data[TESLA_DOMAIN][config_entry.entry_id]["controller"], + config_entry, + ) + for device in hass.data[TESLA_DOMAIN][config_entry.entry_id]["devices"][ + "devices_tracker" + ] + ] + async_add_entities(entities, True) -class TeslaDeviceTracker: +class TeslaDeviceEntity(TeslaDevice, TrackerEntity): """A class representing a Tesla device.""" - def __init__(self, hass, config, see, tesla_devices): + def __init__(self, tesla_device, controller, config_entry): """Initialize the Tesla device scanner.""" - self.hass = hass - self.see = see - self.devices = tesla_devices + super().__init__(tesla_device, controller, config_entry) + self._latitude = None + self._longitude = None + self._attributes = {"trackr_id": self.unique_id} + self._listener = None - async def update_info(self, now=None): + async def async_update(self): """Update the device info.""" - for device in self.devices: - await device.async_update() - name = device.name - _LOGGER.debug("Updating device position: %s", name) - dev_id = slugify(device.uniq_name) - location = device.get_location() - if location: - lat = location["latitude"] - lon = location["longitude"] - attrs = {"trackr_id": dev_id, "id": dev_id, "name": name} - await self.see( - dev_id=dev_id, host_name=name, gps=(lat, lon), attributes=attrs - ) + _LOGGER.debug("Updating device position: %s", self.name) + await super().async_update() + location = self.tesla_device.get_location() + if location: + self._latitude = location["latitude"] + self._longitude = location["longitude"] + self._attributes = { + "trackr_id": self.unique_id, + "heading": location["heading"], + "speed": location["speed"], + } + + @property + def latitude(self) -> float: + """Return latitude value of the device.""" + return self._latitude + + @property + def longitude(self) -> float: + """Return longitude value of the device.""" + return self._longitude + + @property + def should_poll(self): + """Return whether polling is needed.""" + return True + + @property + def source_type(self): + """Return the source type, eg gps or router, of the device.""" + return SOURCE_TYPE_GPS diff --git a/homeassistant/components/tesla/lock.py b/homeassistant/components/tesla/lock.py index 5e97602357d..33eed8cf7c1 100644 --- a/homeassistant/components/tesla/lock.py +++ b/homeassistant/components/tesla/lock.py @@ -9,22 +9,31 @@ from . import DOMAIN as TESLA_DOMAIN, TeslaDevice _LOGGER = logging.getLogger(__name__) -async def async_setup_platform(hass, config, add_entities, discovery_info=None): +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the Tesla lock platform.""" - devices = [ - TeslaLock(device, hass.data[TESLA_DOMAIN]["controller"]) - for device in hass.data[TESLA_DOMAIN]["devices"]["lock"] + pass + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the Tesla binary_sensors by config_entry.""" + entities = [ + TeslaLock( + device, + hass.data[TESLA_DOMAIN][config_entry.entry_id]["controller"], + config_entry, + ) + for device in hass.data[TESLA_DOMAIN][config_entry.entry_id]["devices"]["lock"] ] - add_entities(devices, True) + async_add_entities(entities, True) class TeslaLock(TeslaDevice, LockDevice): """Representation of a Tesla door lock.""" - def __init__(self, tesla_device, controller): + def __init__(self, tesla_device, controller, config_entry): """Initialise of the lock.""" self._state = None - super().__init__(tesla_device, controller) + super().__init__(tesla_device, controller, config_entry) async def async_lock(self, **kwargs): """Send the lock command.""" diff --git a/homeassistant/components/tesla/manifest.json b/homeassistant/components/tesla/manifest.json index a2021092413..4a869ab0a41 100644 --- a/homeassistant/components/tesla/manifest.json +++ b/homeassistant/components/tesla/manifest.json @@ -1,8 +1,9 @@ { "domain": "tesla", "name": "Tesla", + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/tesla", "requirements": ["teslajsonpy==0.2.0"], "dependencies": [], - "codeowners": ["@zabuldon"] + "codeowners": ["@zabuldon", "@alandtse"] } diff --git a/homeassistant/components/tesla/sensor.py b/homeassistant/components/tesla/sensor.py index 1cce37f232a..d93d3fa45d6 100644 --- a/homeassistant/components/tesla/sensor.py +++ b/homeassistant/components/tesla/sensor.py @@ -14,30 +14,34 @@ from . import DOMAIN as TESLA_DOMAIN, TeslaDevice _LOGGER = logging.getLogger(__name__) -async def async_setup_platform(hass, config, add_entities, discovery_info=None): +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the Tesla sensor platform.""" - controller = hass.data[TESLA_DOMAIN]["devices"]["controller"] - devices = [] + pass - for device in hass.data[TESLA_DOMAIN]["devices"]["sensor"]: + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the Tesla binary_sensors by config_entry.""" + controller = hass.data[TESLA_DOMAIN][config_entry.entry_id]["controller"] + entities = [] + for device in hass.data[TESLA_DOMAIN][config_entry.entry_id]["devices"]["sensor"]: if device.bin_type == 0x4: - devices.append(TeslaSensor(device, controller, "inside")) - devices.append(TeslaSensor(device, controller, "outside")) + entities.append(TeslaSensor(device, controller, config_entry, "inside")) + entities.append(TeslaSensor(device, controller, config_entry, "outside")) elif device.bin_type in [0xA, 0xB, 0x5]: - devices.append(TeslaSensor(device, controller)) - add_entities(devices, True) + entities.append(TeslaSensor(device, controller, config_entry)) + async_add_entities(entities, True) class TeslaSensor(TeslaDevice, Entity): """Representation of Tesla sensors.""" - def __init__(self, tesla_device, controller, sensor_type=None): + def __init__(self, tesla_device, controller, config_entry, sensor_type=None): """Initialize of the sensor.""" self.current_value = None self._unit = None self.last_changed_time = None self.type = sensor_type - super().__init__(tesla_device, controller) + super().__init__(tesla_device, controller, config_entry) if self.type: self._name = f"{self.tesla_device.name} ({self.type})" diff --git a/homeassistant/components/tesla/strings.json b/homeassistant/components/tesla/strings.json new file mode 100644 index 00000000000..831406a0d63 --- /dev/null +++ b/homeassistant/components/tesla/strings.json @@ -0,0 +1,30 @@ +{ + "config": { + "error": { + "connection_error": "Error connecting; check network and retry", + "identifier_exists": "Email already registered", + "invalid_credentials": "Invalid credentials", + "unknown_error": "Unknown error, please report log info" + }, + "step": { + "user": { + "data": { + "username": "Email Address", + "password": "Password" + }, + "description": "Please enter your information.", + "title": "Tesla - Configuration" + } + }, + "title": "Tesla" + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "Seconds between scans" + } + } + } + } +} diff --git a/homeassistant/components/tesla/switch.py b/homeassistant/components/tesla/switch.py index 5f432875aeb..3fc424e390d 100644 --- a/homeassistant/components/tesla/switch.py +++ b/homeassistant/components/tesla/switch.py @@ -9,26 +9,31 @@ from . import DOMAIN as TESLA_DOMAIN, TeslaDevice _LOGGER = logging.getLogger(__name__) -async def async_setup_platform(hass, config, add_entities, discovery_info=None): +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the Tesla switch platform.""" - controller = hass.data[TESLA_DOMAIN]["controller"] - devices = [] - for device in hass.data[TESLA_DOMAIN]["devices"]["switch"]: + pass + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the Tesla binary_sensors by config_entry.""" + controller = hass.data[TESLA_DOMAIN][config_entry.entry_id]["controller"] + entities = [] + for device in hass.data[TESLA_DOMAIN][config_entry.entry_id]["devices"]["switch"]: if device.bin_type == 0x8: - devices.append(ChargerSwitch(device, controller)) - devices.append(UpdateSwitch(device, controller)) + entities.append(ChargerSwitch(device, controller, config_entry)) + entities.append(UpdateSwitch(device, controller, config_entry)) elif device.bin_type == 0x9: - devices.append(RangeSwitch(device, controller)) - add_entities(devices, True) + entities.append(RangeSwitch(device, controller, config_entry)) + async_add_entities(entities, True) class ChargerSwitch(TeslaDevice, SwitchDevice): """Representation of a Tesla charger switch.""" - def __init__(self, tesla_device, controller): + def __init__(self, tesla_device, controller, config_entry): """Initialise of the switch.""" self._state = None - super().__init__(tesla_device, controller) + super().__init__(tesla_device, controller, config_entry) async def async_turn_on(self, **kwargs): """Send the on command.""" @@ -55,10 +60,10 @@ class ChargerSwitch(TeslaDevice, SwitchDevice): class RangeSwitch(TeslaDevice, SwitchDevice): """Representation of a Tesla max range charging switch.""" - def __init__(self, tesla_device, controller): + def __init__(self, tesla_device, controller, config_entry): """Initialise the switch.""" self._state = None - super().__init__(tesla_device, controller) + super().__init__(tesla_device, controller, config_entry) async def async_turn_on(self, **kwargs): """Send the on command.""" @@ -85,11 +90,11 @@ class RangeSwitch(TeslaDevice, SwitchDevice): class UpdateSwitch(TeslaDevice, SwitchDevice): """Representation of a Tesla update switch.""" - def __init__(self, tesla_device, controller): + def __init__(self, tesla_device, controller, config_entry): """Initialise the switch.""" self._state = None tesla_device.type = "update switch" - super().__init__(tesla_device, controller) + super().__init__(tesla_device, controller, config_entry) self._name = self._name.replace("charger", "update") self.tesla_id = self.tesla_id.replace("charger", "update") diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 2b3940000e7..88ff92a57b0 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -73,6 +73,7 @@ FLOWS = [ "sonos", "starline", "tellduslive", + "tesla", "toon", "tplink", "traccar", diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e5361e7464e..2b7101dfa25 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -598,6 +598,9 @@ sunwatcher==0.2.1 # homeassistant.components.tellduslive tellduslive==0.10.10 +# homeassistant.components.tesla +teslajsonpy==0.2.0 + # homeassistant.components.toon toonapilib==3.2.4 diff --git a/tests/components/tesla/__init__.py b/tests/components/tesla/__init__.py new file mode 100644 index 00000000000..89b1e1c0c54 --- /dev/null +++ b/tests/components/tesla/__init__.py @@ -0,0 +1 @@ +"""Tests for the Tesla integration.""" diff --git a/tests/components/tesla/test_config_flow.py b/tests/components/tesla/test_config_flow.py new file mode 100644 index 00000000000..b6eeff54a50 --- /dev/null +++ b/tests/components/tesla/test_config_flow.py @@ -0,0 +1,160 @@ +"""Test the Tesla config flow.""" +from unittest.mock import patch + +from teslajsonpy import TeslaException + +from homeassistant import config_entries, data_entry_flow, setup +from homeassistant.components.tesla.const import DOMAIN +from homeassistant.const import ( + CONF_ACCESS_TOKEN, + CONF_PASSWORD, + CONF_SCAN_INTERVAL, + CONF_TOKEN, + CONF_USERNAME, +) + +from tests.common import MockConfigEntry, mock_coro + + +async def test_form(hass): + """Test we get the form.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["errors"] == {} + + with patch( + "homeassistant.components.tesla.config_flow.TeslaAPI.connect", + return_value=mock_coro(("test-refresh-token", "test-access-token")), + ), patch( + "homeassistant.components.tesla.async_setup", return_value=mock_coro(True) + ) as mock_setup, patch( + "homeassistant.components.tesla.async_setup_entry", return_value=mock_coro(True) + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_PASSWORD: "test", CONF_USERNAME: "test@email.com"} + ) + + assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result2["title"] == "test@email.com" + assert result2["data"] == { + "token": "test-refresh-token", + "access_token": "test-access-token", + } + await hass.async_block_till_done() + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_invalid_auth(hass): + """Test we handle invalid auth.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.tesla.config_flow.TeslaAPI.connect", + side_effect=TeslaException(401), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_USERNAME: "test-username", CONF_PASSWORD: "test-password"}, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "invalid_credentials"} + + +async def test_form_cannot_connect(hass): + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.tesla.config_flow.TeslaAPI.connect", + side_effect=TeslaException(code=404), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_PASSWORD: "test-password", CONF_USERNAME: "test-username"}, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "connection_error"} + + +async def test_form_repeat_identifier(hass): + """Test we handle repeat identifiers.""" + entry = MockConfigEntry(domain=DOMAIN, title="test-username", data={}, options=None) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + with patch( + "homeassistant.components.tesla.config_flow.TeslaAPI.connect", + return_value=mock_coro(("test-refresh-token", "test-access-token")), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_USERNAME: "test-username", CONF_PASSWORD: "test-password"}, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {CONF_USERNAME: "identifier_exists"} + + +async def test_import(hass): + """Test import step.""" + + with patch( + "homeassistant.components.tesla.config_flow.TeslaAPI.connect", + return_value=mock_coro(("test-refresh-token", "test-access-token")), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={CONF_PASSWORD: "test-password", CONF_USERNAME: "test-username"}, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "test-username" + assert result["data"][CONF_ACCESS_TOKEN] == "test-access-token" + assert result["data"][CONF_TOKEN] == "test-refresh-token" + assert result["description_placeholders"] is None + + +async def test_option_flow(hass): + """Test config flow options.""" + entry = MockConfigEntry(domain=DOMAIN, data={}, options=None) + entry.add_to_hass(hass) + + result = await hass.config_entries.options.flow.async_init(entry.entry_id) + + assert result["type"] == "form" + assert result["step_id"] == "init" + + result = await hass.config_entries.options.flow.async_configure( + result["flow_id"], user_input={CONF_SCAN_INTERVAL: 350} + ) + assert result["type"] == "create_entry" + assert result["data"] == {CONF_SCAN_INTERVAL: 350} + + +async def test_option_flow_input_floor(hass): + """Test config flow options.""" + entry = MockConfigEntry(domain=DOMAIN, data={}, options=None) + entry.add_to_hass(hass) + + result = await hass.config_entries.options.flow.async_init(entry.entry_id) + + assert result["type"] == "form" + assert result["step_id"] == "init" + + result = await hass.config_entries.options.flow.async_configure( + result["flow_id"], user_input={CONF_SCAN_INTERVAL: 1} + ) + assert result["type"] == "create_entry" + assert result["data"] == {CONF_SCAN_INTERVAL: 300}