From 4d345e0665f9a03edfb3c17177cbee90cae508e0 Mon Sep 17 00:00:00 2001 From: einarhauks Date: Sun, 28 Nov 2021 17:41:01 +0000 Subject: [PATCH] Add Tesla Wall Connector integration (#60000) --- CODEOWNERS | 1 + .../tesla_wall_connector/__init__.py | 173 +++++++++++++++ .../tesla_wall_connector/binary_sensor.py | 77 +++++++ .../tesla_wall_connector/config_flow.py | 160 ++++++++++++++ .../components/tesla_wall_connector/const.py | 11 + .../tesla_wall_connector/manifest.json | 25 +++ .../tesla_wall_connector/strings.json | 30 +++ .../tesla_wall_connector/translations/en.json | 30 +++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/dhcp.py | 15 ++ requirements_all.txt | 3 + requirements_test_all.txt | 3 + .../tesla_wall_connector/__init__.py | 1 + .../tesla_wall_connector/conftest.py | 72 ++++++ .../tesla_wall_connector/test_config_flow.py | 207 ++++++++++++++++++ .../tesla_wall_connector/test_init.py | 35 +++ 16 files changed, 844 insertions(+) create mode 100644 homeassistant/components/tesla_wall_connector/__init__.py create mode 100644 homeassistant/components/tesla_wall_connector/binary_sensor.py create mode 100644 homeassistant/components/tesla_wall_connector/config_flow.py create mode 100644 homeassistant/components/tesla_wall_connector/const.py create mode 100644 homeassistant/components/tesla_wall_connector/manifest.json create mode 100644 homeassistant/components/tesla_wall_connector/strings.json create mode 100644 homeassistant/components/tesla_wall_connector/translations/en.json create mode 100644 tests/components/tesla_wall_connector/__init__.py create mode 100644 tests/components/tesla_wall_connector/conftest.py create mode 100644 tests/components/tesla_wall_connector/test_config_flow.py create mode 100644 tests/components/tesla_wall_connector/test_init.py diff --git a/CODEOWNERS b/CODEOWNERS index c3e623ca482..bf9b88d5eec 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -534,6 +534,7 @@ homeassistant/components/tasmota/* @emontnemery homeassistant/components/tautulli/* @ludeeus homeassistant/components/tellduslive/* @fredrike homeassistant/components/template/* @PhracturedBlue @tetienne @home-assistant/core +homeassistant/components/tesla_wall_connector/* @einarhauks homeassistant/components/tfiac/* @fredrike @mellado homeassistant/components/thethingsnetwork/* @fabaff homeassistant/components/threshold/* @fabaff diff --git a/homeassistant/components/tesla_wall_connector/__init__.py b/homeassistant/components/tesla_wall_connector/__init__.py new file mode 100644 index 00000000000..0796f4660d4 --- /dev/null +++ b/homeassistant/components/tesla_wall_connector/__init__.py @@ -0,0 +1,173 @@ +"""The Tesla Wall Connector integration.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from datetime import timedelta +import logging +from typing import Any + +from tesla_wall_connector import WallConnector +from tesla_wall_connector.exceptions import ( + WallConnectorConnectionError, + WallConnectorConnectionTimeoutError, + WallConnectorError, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_SCAN_INTERVAL +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, + UpdateFailed, +) + +from .const import ( + DEFAULT_SCAN_INTERVAL, + DOMAIN, + WALLCONNECTOR_DATA_LIFETIME, + WALLCONNECTOR_DATA_VITALS, + WALLCONNECTOR_DEVICE_NAME, +) + +PLATFORMS: list[str] = ["binary_sensor"] + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Tesla Wall Connector from a config entry.""" + hass.data.setdefault(DOMAIN, {}) + hostname = entry.data[CONF_HOST] + + wall_connector = WallConnector(host=hostname, session=async_get_clientsession(hass)) + + try: + version_data = await wall_connector.async_get_version() + except WallConnectorError as ex: + raise ConfigEntryNotReady from ex + + async def async_update_data(): + """Fetch new data from the Wall Connector.""" + try: + vitals = await wall_connector.async_get_vitals() + lifetime = await wall_connector.async_get_lifetime() + except WallConnectorConnectionTimeoutError as ex: + raise UpdateFailed( + f"Could not fetch data from Tesla WallConnector at {hostname}: Timeout" + ) from ex + except WallConnectorConnectionError as ex: + raise UpdateFailed( + f"Could not fetch data from Tesla WallConnector at {hostname}: Cannot connect" + ) from ex + except WallConnectorError as ex: + raise UpdateFailed( + f"Could not fetch data from Tesla WallConnector at {hostname}: {ex}" + ) from ex + + return { + WALLCONNECTOR_DATA_VITALS: vitals, + WALLCONNECTOR_DATA_LIFETIME: lifetime, + } + + coordinator: DataUpdateCoordinator = DataUpdateCoordinator( + hass, + _LOGGER, + name="tesla-wallconnector", + update_interval=get_poll_interval(entry), + update_method=async_update_data, + ) + + await coordinator.async_config_entry_first_refresh() + + hass.data[DOMAIN][entry.entry_id] = WallConnectorData( + wall_connector_client=wall_connector, + hostname=hostname, + part_number=version_data.part_number, + firmware_version=version_data.firmware_version, + serial_number=version_data.serial_number, + update_coordinator=coordinator, + ) + + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + + entry.async_on_unload(entry.add_update_listener(update_listener)) + + return True + + +def get_poll_interval(entry: ConfigEntry) -> timedelta: + """Get the poll interval from config.""" + return timedelta( + seconds=entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) + ) + + +async def update_listener(hass, entry): + """Handle options update.""" + wall_connector_data: WallConnectorData = hass.data[DOMAIN][entry.entry_id] + wall_connector_data.update_coordinator.update_interval = get_poll_interval(entry) + + +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 + + +def prefix_entity_name(name: str) -> str: + """Prefixes entity name.""" + return f"{WALLCONNECTOR_DEVICE_NAME} {name}" + + +def get_unique_id(serial_number: str, key: str) -> str: + """Get a unique entity name.""" + return f"{serial_number}-{key}" + + +class WallConnectorEntity(CoordinatorEntity): + """Base class for Wall Connector entities.""" + + def __init__(self, wall_connector_data: WallConnectorData) -> None: + """Initialize WallConnector Entity.""" + self.wall_connector_data = wall_connector_data + self._attr_unique_id = get_unique_id( + wall_connector_data.serial_number, self.entity_description.key + ) + super().__init__(wall_connector_data.update_coordinator) + + @property + def device_info(self) -> DeviceInfo: + """Return information about the device.""" + return DeviceInfo( + identifiers={(DOMAIN, self.wall_connector_data.serial_number)}, + default_name=WALLCONNECTOR_DEVICE_NAME, + model=self.wall_connector_data.part_number, + sw_version=self.wall_connector_data.firmware_version, + default_manufacturer="Tesla", + ) + + +@dataclass() +class WallConnectorLambdaValueGetterMixin: + """Mixin with a function pointer for getting sensor value.""" + + value_fn: Callable[[dict], Any] + + +@dataclass +class WallConnectorData: + """Data for the Tesla Wall Connector integration.""" + + wall_connector_client: WallConnector + update_coordinator: DataUpdateCoordinator + hostname: str + part_number: str + firmware_version: str + serial_number: str diff --git a/homeassistant/components/tesla_wall_connector/binary_sensor.py b/homeassistant/components/tesla_wall_connector/binary_sensor.py new file mode 100644 index 00000000000..8aef4f86478 --- /dev/null +++ b/homeassistant/components/tesla_wall_connector/binary_sensor.py @@ -0,0 +1,77 @@ +"""Binary Sensors for Tesla Wall Connector.""" +from dataclasses import dataclass +import logging + +from homeassistant.components.binary_sensor import ( + DEVICE_CLASS_BATTERY_CHARGING, + DEVICE_CLASS_PLUG, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.const import ENTITY_CATEGORY_DIAGNOSTIC + +from . import ( + WallConnectorData, + WallConnectorEntity, + WallConnectorLambdaValueGetterMixin, + prefix_entity_name, +) +from .const import DOMAIN, WALLCONNECTOR_DATA_VITALS + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class WallConnectorBinarySensorDescription( + BinarySensorEntityDescription, WallConnectorLambdaValueGetterMixin +): + """Binary Sensor entity description.""" + + +WALL_CONNECTOR_SENSORS = [ + WallConnectorBinarySensorDescription( + key="vehicle_connected", + name=prefix_entity_name("Vehicle connected"), + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + value_fn=lambda data: data[WALLCONNECTOR_DATA_VITALS].vehicle_connected, + device_class=DEVICE_CLASS_PLUG, + ), + WallConnectorBinarySensorDescription( + key="contactor_closed", + name=prefix_entity_name("Contactor closed"), + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + value_fn=lambda data: data[WALLCONNECTOR_DATA_VITALS].contactor_closed, + device_class=DEVICE_CLASS_BATTERY_CHARGING, + ), +] + + +async def async_setup_entry(hass, config_entry, async_add_devices): + """Create the Wall Connector sensor devices.""" + wall_connector_data = hass.data[DOMAIN][config_entry.entry_id] + + all_entities = [ + WallConnectorBinarySensorEntity(wall_connector_data, description) + for description in WALL_CONNECTOR_SENSORS + ] + + async_add_devices(all_entities) + + +class WallConnectorBinarySensorEntity(WallConnectorEntity, BinarySensorEntity): + """Wall Connector Sensor Entity.""" + + def __init__( + self, + wall_connectord_data: WallConnectorData, + description: WallConnectorBinarySensorDescription, + ) -> None: + """Initialize WallConnectorBinarySensorEntity.""" + self.entity_description = description + super().__init__(wall_connectord_data) + + @property + def is_on(self): + """Return the state of the sensor.""" + + return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/tesla_wall_connector/config_flow.py b/homeassistant/components/tesla_wall_connector/config_flow.py new file mode 100644 index 00000000000..8b4dc423fbb --- /dev/null +++ b/homeassistant/components/tesla_wall_connector/config_flow.py @@ -0,0 +1,160 @@ +"""Config flow for Tesla Wall Connector integration.""" +from __future__ import annotations + +import logging +from typing import Any + +from tesla_wall_connector import WallConnector +from tesla_wall_connector.exceptions import WallConnectorError +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.components.dhcp import IP_ADDRESS +from homeassistant.const import CONF_HOST, CONF_SCAN_INTERVAL +from homeassistant.core import HomeAssistant, callback +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import ( + DEFAULT_SCAN_INTERVAL, + DOMAIN, + WALLCONNECTOR_DEVICE_NAME, + WALLCONNECTOR_SERIAL_NUMBER, +) + +_LOGGER = logging.getLogger(__name__) + + +async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]: + """Validate the user input allows us to connect. + + Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. + """ + wall_connector = WallConnector( + host=data[CONF_HOST], session=async_get_clientsession(hass) + ) + + version = await wall_connector.async_get_version() + + return { + "title": WALLCONNECTOR_DEVICE_NAME, + WALLCONNECTOR_SERIAL_NUMBER: version.serial_number, + } + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Tesla Wall Connector.""" + + VERSION = 1 + + def __init__(self) -> None: + """Initialize config flow.""" + super().__init__() + self.ip_address = None + self.serial_number = None + + async def async_step_dhcp(self, discovery_info) -> FlowResult: + """Handle dhcp discovery.""" + self.ip_address = discovery_info[IP_ADDRESS] + _LOGGER.debug("Discovered Tesla Wall Connector at [%s]", self.ip_address) + + self._async_abort_entries_match({CONF_HOST: self.ip_address}) + + try: + wall_connector = WallConnector( + host=self.ip_address, session=async_get_clientsession(self.hass) + ) + version = await wall_connector.async_get_version() + except WallConnectorError as ex: + _LOGGER.debug( + "Could not read serial number from Tesla WallConnector at [%s]: [%s]", + self.ip_address, + ex, + ) + return self.async_abort(reason="cannot_connect") + + self.serial_number = version.serial_number + + await self.async_set_unique_id(self.serial_number) + self._abort_if_unique_id_configured(updates={CONF_HOST: self.ip_address}) + + _LOGGER.debug( + "No entry found for wall connector with IP %s. Serial nr: %s", + self.ip_address, + self.serial_number, + ) + + placeholders = { + CONF_HOST: self.ip_address, + WALLCONNECTOR_SERIAL_NUMBER: self.serial_number, + } + + self.context["title_placeholders"] = placeholders + return await self.async_step_user() + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + data_schema = vol.Schema( + {vol.Required(CONF_HOST, default=self.ip_address): str} + ) + if user_input is None: + return self.async_show_form(step_id="user", data_schema=data_schema) + errors = {} + + try: + info = await validate_input(self.hass, user_input) + except WallConnectorError: + errors["base"] = "cannot_connect" + except Exception as ex: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception: %s", ex) + errors["base"] = "unknown" + + if not errors: + existing_entry = await self.async_set_unique_id( + info[WALLCONNECTOR_SERIAL_NUMBER] + ) + if existing_entry: + self.hass.config_entries.async_update_entry( + existing_entry, data=user_input + ) + await self.hass.config_entries.async_reload(existing_entry.entry_id) + return self.async_abort(reason="already_configured") + + return self.async_create_entry(title=info["title"], data=user_input) + + return self.async_show_form( + step_id="user", data_schema=data_schema, errors=errors + ) + + @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 Wall Connector.""" + + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: + """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, DEFAULT_SCAN_INTERVAL + ), + ): vol.All(vol.Coerce(int), vol.Clamp(min=1)) + } + ) + return self.async_show_form(step_id="init", data_schema=data_schema) diff --git a/homeassistant/components/tesla_wall_connector/const.py b/homeassistant/components/tesla_wall_connector/const.py new file mode 100644 index 00000000000..2a660ee1aae --- /dev/null +++ b/homeassistant/components/tesla_wall_connector/const.py @@ -0,0 +1,11 @@ +"""Constants for the Tesla Wall Connector integration.""" + +DOMAIN = "tesla_wall_connector" +DEFAULT_SCAN_INTERVAL = 30 + +WALLCONNECTOR_SERIAL_NUMBER = "serial_number" + +WALLCONNECTOR_DATA_VITALS = "vitals" +WALLCONNECTOR_DATA_LIFETIME = "lifetime" + +WALLCONNECTOR_DEVICE_NAME = "Tesla Wall Connector" diff --git a/homeassistant/components/tesla_wall_connector/manifest.json b/homeassistant/components/tesla_wall_connector/manifest.json new file mode 100644 index 00000000000..bf03f51b13f --- /dev/null +++ b/homeassistant/components/tesla_wall_connector/manifest.json @@ -0,0 +1,25 @@ +{ + "domain": "tesla_wall_connector", + "name": "Tesla Wall Connector", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/tesla_wall_connector", + "requirements": ["tesla-wall-connector==0.2.0"], + "dhcp": [ + { + "hostname": "teslawallconnector_*", + "macaddress": "DC44271*" + }, + { + "hostname": "teslawallconnector_*", + "macaddress": "98ED5C*" + }, + { + "hostname": "teslawallconnector_*", + "macaddress": "4CFCAA*" + } + ], + "codeowners": [ + "@einarhauks" + ], + "iot_class": "local_polling" +} \ No newline at end of file diff --git a/homeassistant/components/tesla_wall_connector/strings.json b/homeassistant/components/tesla_wall_connector/strings.json new file mode 100644 index 00000000000..78d3556fbd1 --- /dev/null +++ b/homeassistant/components/tesla_wall_connector/strings.json @@ -0,0 +1,30 @@ +{ + "config": { + "flow_title": "{serial_number} ({host})", + "step": { + "user": { + "title": "Configure Tesla Wall Connector", + "data": { + "host": "[%key:common::config_flow::data::host%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + }, + "options": { + "step": { + "init": { + "title": "Configure options for Tesla Wall Connector", + "data": { + "scan_interval": "Update frequency" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tesla_wall_connector/translations/en.json b/homeassistant/components/tesla_wall_connector/translations/en.json new file mode 100644 index 00000000000..79a3005299f --- /dev/null +++ b/homeassistant/components/tesla_wall_connector/translations/en.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured" + }, + "error": { + "cannot_connect": "Failed to connect", + "unknown": "Unexpected error" + }, + "flow_title": "{serial_number} ({host})", + "step": { + "user": { + "data": { + "host": "Host" + }, + "title": "Configure Tesla Wall Connector" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "Update frequency" + }, + "title": "Configure options for Tesla Wall Connector" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 0c0ea7964e0..b8ba8c4aade 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -297,6 +297,7 @@ FLOWS = [ "tado", "tasmota", "tellduslive", + "tesla_wall_connector", "tibber", "tile", "tolo", diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index 59f346e0be0..17189705056 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -361,6 +361,21 @@ DHCP = [ "domain": "tado", "hostname": "tado*" }, + { + "domain": "tesla_wall_connector", + "hostname": "teslawallconnector_*", + "macaddress": "DC44271*" + }, + { + "domain": "tesla_wall_connector", + "hostname": "teslawallconnector_*", + "macaddress": "98ED5C*" + }, + { + "domain": "tesla_wall_connector", + "hostname": "teslawallconnector_*", + "macaddress": "4CFCAA*" + }, { "domain": "tolo", "hostname": "usr-tcp232-ed2" diff --git a/requirements_all.txt b/requirements_all.txt index 05efb307062..3d47a00fe2e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2301,6 +2301,9 @@ temperusb==1.5.3 # homeassistant.components.powerwall tesla-powerwall==0.3.12 +# homeassistant.components.tesla_wall_connector +tesla-wall-connector==0.2.0 + # homeassistant.components.tensorflow # tf-models-official==2.3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 86012569af0..46e7f97a7f2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1353,6 +1353,9 @@ tellduslive==0.10.11 # homeassistant.components.powerwall tesla-powerwall==0.3.12 +# homeassistant.components.tesla_wall_connector +tesla-wall-connector==0.2.0 + # homeassistant.components.tolo tololib==0.1.0b3 diff --git a/tests/components/tesla_wall_connector/__init__.py b/tests/components/tesla_wall_connector/__init__.py new file mode 100644 index 00000000000..cd5c6308425 --- /dev/null +++ b/tests/components/tesla_wall_connector/__init__.py @@ -0,0 +1 @@ +"""Tests for the Tesla Wall Connector integration.""" diff --git a/tests/components/tesla_wall_connector/conftest.py b/tests/components/tesla_wall_connector/conftest.py new file mode 100644 index 00000000000..a22061e197e --- /dev/null +++ b/tests/components/tesla_wall_connector/conftest.py @@ -0,0 +1,72 @@ +"""Common fixutres with default mocks as well as common test helper methods.""" +from unittest.mock import patch + +import pytest +import tesla_wall_connector + +from homeassistant.components.tesla_wall_connector.const import DOMAIN +from homeassistant.const import CONF_HOST, CONF_SCAN_INTERVAL +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_wall_connector_version(): + """Fixture to mock get_version calls to the wall connector API.""" + + with patch( + "tesla_wall_connector.WallConnector.async_get_version", + return_value=get_default_version_data(), + ): + yield + + +def get_default_version_data(): + """Return default version data object for a wall connector.""" + return tesla_wall_connector.wall_connector.Version( + { + "serial_number": "abc123", + "part_number": "part_123", + "firmware_version": "1.2.3", + } + ) + + +async def create_wall_connector_entry( + hass: HomeAssistant, side_effect=None +) -> MockConfigEntry: + """Create a wall connector entry in hass.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "1.2.3.4"}, + options={CONF_SCAN_INTERVAL: 30}, + ) + + entry.add_to_hass(hass) + + # We need to return vitals with a contactor_closed attribute + # Since that is used to determine the update scan interval + fake_vitals = tesla_wall_connector.wall_connector.Vitals( + { + "contactor_closed": "false", + } + ) + + with patch( + "tesla_wall_connector.WallConnector.async_get_version", + return_value=get_default_version_data(), + side_effect=side_effect, + ), patch( + "tesla_wall_connector.WallConnector.async_get_vitals", + return_value=fake_vitals, + side_effect=side_effect, + ), patch( + "tesla_wall_connector.WallConnector.async_get_lifetime", + return_value=None, + side_effect=side_effect, + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + return entry diff --git a/tests/components/tesla_wall_connector/test_config_flow.py b/tests/components/tesla_wall_connector/test_config_flow.py new file mode 100644 index 00000000000..e28f0749b5a --- /dev/null +++ b/tests/components/tesla_wall_connector/test_config_flow.py @@ -0,0 +1,207 @@ +"""Test the Tesla Wall Connector config flow.""" +from unittest.mock import patch + +from tesla_wall_connector.exceptions import WallConnectorConnectionError + +from homeassistant import config_entries, setup +from homeassistant.components.dhcp import HOSTNAME, IP_ADDRESS, MAC_ADDRESS +from homeassistant.components.tesla_wall_connector.const import DOMAIN +from homeassistant.const import CONF_HOST, CONF_SCAN_INTERVAL +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import RESULT_TYPE_CREATE_ENTRY, RESULT_TYPE_FORM + +from tests.common import MockConfigEntry + + +async def test_form(mock_wall_connector_version, hass: HomeAssistant) -> None: + """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"] == RESULT_TYPE_FORM + assert result["errors"] is None + + with patch( + "homeassistant.components.tesla_wall_connector.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "1.1.1.1"}, + ) + await hass.async_block_till_done() + + assert result2["type"] == RESULT_TYPE_CREATE_ENTRY + assert result2["title"] == "Tesla Wall Connector" + assert result2["data"] == {CONF_HOST: "1.1.1.1"} + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_cannot_connect(hass: HomeAssistant) -> None: + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "tesla_wall_connector.WallConnector.async_get_version", + side_effect=WallConnectorConnectionError, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "1.1.1.1"}, + ) + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_form_other_error( + mock_wall_connector_version, hass: HomeAssistant +) -> None: + """Test we handle any other error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "tesla_wall_connector.WallConnector.async_get_version", + side_effect=Exception, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "1.1.1.1"}, + ) + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["errors"] == {"base": "unknown"} + + +async def test_form_already_configured(mock_wall_connector_version, hass): + """Test we get already configured.""" + + entry = MockConfigEntry( + domain=DOMAIN, unique_id="abc123", data={CONF_HOST: "0.0.0.0"} + ) + 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_wall_connector.async_setup_entry", + return_value=True, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "1.1.1.1"}, + ) + await hass.async_block_till_done() + + assert result2["type"] == "abort" + assert result2["reason"] == "already_configured" + + # Test config entry got updated with latest IP + assert entry.data[CONF_HOST] == "1.1.1.1" + + +async def test_dhcp_can_finish(mock_wall_connector_version, hass): + """Test DHCP discovery flow can finish right away.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data={ + HOSTNAME: "teslawallconnector_abc", + IP_ADDRESS: "1.2.3.4", + MAC_ADDRESS: "DC:44:27:12:12", + }, + ) + await hass.async_block_till_done() + assert result["type"] == "form" + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["data"] == {CONF_HOST: "1.2.3.4"} + + +async def test_dhcp_already_exists(mock_wall_connector_version, hass): + """Test DHCP discovery flow when device already exists.""" + + entry = MockConfigEntry( + domain=DOMAIN, unique_id="abc123", data={CONF_HOST: "1.2.3.4"} + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data={ + HOSTNAME: "teslawallconnector_aabbcc", + IP_ADDRESS: "1.2.3.4", + MAC_ADDRESS: "aa:bb:cc:dd:ee:ff", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == "abort" + assert result["reason"] == "already_configured" + + +async def test_dhcp_error_from_wall_connector(mock_wall_connector_version, hass): + """Test DHCP discovery flow when we cannot communicate with the device.""" + + with patch( + "tesla_wall_connector.WallConnector.async_get_version", + side_effect=WallConnectorConnectionError, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data={ + HOSTNAME: "teslawallconnector_aabbcc", + IP_ADDRESS: "1.2.3.4", + MAC_ADDRESS: "aa:bb:cc:dd:ee:ff", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == "abort" + assert result["reason"] == "cannot_connect" + + +async def test_option_flow(hass): + """Test option flow.""" + entry = MockConfigEntry( + domain=DOMAIN, unique_id="abc123", data={CONF_HOST: "1.2.3.4"} + ) + entry.add_to_hass(hass) + + assert not entry.options + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.options.async_init( + entry.entry_id, + data=None, + ) + + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={CONF_SCAN_INTERVAL: 30}, + ) + + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["data"] == {CONF_SCAN_INTERVAL: 30} diff --git a/tests/components/tesla_wall_connector/test_init.py b/tests/components/tesla_wall_connector/test_init.py new file mode 100644 index 00000000000..b86a363dc3c --- /dev/null +++ b/tests/components/tesla_wall_connector/test_init.py @@ -0,0 +1,35 @@ +"""Test the Tesla Wall Connector config flow.""" +from tesla_wall_connector.exceptions import WallConnectorConnectionError + +from homeassistant import config_entries +from homeassistant.core import HomeAssistant + +from .conftest import create_wall_connector_entry + + +async def test_init_success(hass: HomeAssistant) -> None: + """Test setup and that we get the device info, including firmware version.""" + + entry = await create_wall_connector_entry(hass) + + assert entry.state == config_entries.ConfigEntryState.LOADED + + +async def test_init_while_offline(hass: HomeAssistant) -> None: + """Test init with the wall connector offline.""" + entry = await create_wall_connector_entry( + hass, side_effect=WallConnectorConnectionError + ) + + assert entry.state == config_entries.ConfigEntryState.SETUP_RETRY + + +async def test_load_unload(hass): + """Config entry can be unloaded.""" + + entry = await create_wall_connector_entry(hass) + + assert entry.state is config_entries.ConfigEntryState.LOADED + + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done()