diff --git a/homeassistant/components/adax/climate.py b/homeassistant/components/adax/climate.py index 783c2a9f2f8..48cbc9b270c 100644 --- a/homeassistant/components/adax/climate.py +++ b/homeassistant/components/adax/climate.py @@ -4,6 +4,7 @@ from __future__ import annotations from typing import Any from adax import Adax +from adax_local import Adax as AdaxLocal from homeassistant.components.climate import ClimateEntity from homeassistant.components.climate.const import ( @@ -14,7 +15,10 @@ from homeassistant.components.climate.const import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_TEMPERATURE, + CONF_IP_ADDRESS, CONF_PASSWORD, + CONF_TOKEN, + CONF_UNIQUE_ID, PRECISION_WHOLE, TEMP_CELSIUS, ) @@ -23,7 +27,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import ACCOUNT_ID, DOMAIN +from .const import ACCOUNT_ID, CONNECTION_TYPE, DOMAIN, LOCAL async def async_setup_entry( @@ -32,6 +36,17 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Adax thermostat with config flow.""" + if entry.data.get(CONNECTION_TYPE) == LOCAL: + adax_data_handler = AdaxLocal( + entry.data[CONF_IP_ADDRESS], + entry.data[CONF_TOKEN], + websession=async_get_clientsession(hass, verify_ssl=False), + ) + async_add_entities( + [LocalAdaxDevice(adax_data_handler, entry.data[CONF_UNIQUE_ID])], True + ) + return + adax_data_handler = Adax( entry.data[ACCOUNT_ID], entry.data[CONF_PASSWORD], @@ -107,3 +122,38 @@ class AdaxDevice(ClimateEntity): self._attr_hvac_mode = HVAC_MODE_OFF self._attr_icon = "mdi:radiator-off" return + + +class LocalAdaxDevice(ClimateEntity): + """Representation of a heater.""" + + _attr_hvac_modes = [HVAC_MODE_HEAT] + _attr_hvac_mode = HVAC_MODE_HEAT + _attr_max_temp = 35 + _attr_min_temp = 5 + _attr_supported_features = SUPPORT_TARGET_TEMPERATURE + _attr_target_temperature_step = PRECISION_WHOLE + _attr_temperature_unit = TEMP_CELSIUS + + def __init__(self, adax_data_handler, unique_id): + """Initialize the heater.""" + self._adax_data_handler = adax_data_handler + self._attr_unique_id = unique_id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, unique_id)}, + manufacturer="Adax", + ) + + async def async_set_temperature(self, **kwargs): + """Set new target temperature.""" + temperature = kwargs.get(ATTR_TEMPERATURE) + if temperature is None: + return + await self._adax_data_handler.set_target_temperature(temperature) + + async def async_update(self) -> None: + """Get the latest data.""" + data = await self._adax_data_handler.get_status() + self._attr_target_temperature = data["target_temperature"] + self._attr_current_temperature = data["current_temperature"] + self._attr_available = self._attr_current_temperature is not None diff --git a/homeassistant/components/adax/config_flow.py b/homeassistant/components/adax/config_flow.py index cf845df5e06..2788b563678 100644 --- a/homeassistant/components/adax/config_flow.py +++ b/homeassistant/components/adax/config_flow.py @@ -5,35 +5,30 @@ import logging from typing import Any import adax +import adax_local import voluptuous as vol from homeassistant import config_entries -from homeassistant.const import CONF_PASSWORD -from homeassistant.core import HomeAssistant +from homeassistant.const import ( + CONF_IP_ADDRESS, + CONF_PASSWORD, + CONF_TOKEN, + CONF_UNIQUE_ID, +) from homeassistant.data_entry_flow import FlowResult -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import ACCOUNT_ID, DOMAIN - -_LOGGER = logging.getLogger(__name__) - -STEP_USER_DATA_SCHEMA = vol.Schema( - {vol.Required(ACCOUNT_ID): int, vol.Required(CONF_PASSWORD): str} +from .const import ( + ACCOUNT_ID, + CLOUD, + CONNECTION_TYPE, + DOMAIN, + LOCAL, + WIFI_PSWD, + WIFI_SSID, ) - -async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None: - """Validate the user input allows us to connect.""" - account_id = data[ACCOUNT_ID] - password = data[CONF_PASSWORD].replace(" ", "") - - token = await adax.get_adax_token( - async_get_clientsession(hass), account_id, password - ) - if token is None: - _LOGGER.info("Adax: Failed to login to retrieve token") - raise CannotConnect +_LOGGER = logging.getLogger(__name__) class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @@ -41,33 +36,107 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - async def async_step_user( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: + async def async_step_user(self, user_input=None): """Handle the initial step.""" + data_schema = vol.Schema( + { + vol.Required(CONNECTION_TYPE, default=CLOUD): vol.In( + ( + CLOUD, + LOCAL, + ) + ) + } + ) + if user_input is None: return self.async_show_form( - step_id="user", data_schema=STEP_USER_DATA_SCHEMA + step_id="user", + data_schema=data_schema, ) + if user_input[CONNECTION_TYPE] == LOCAL: + return await self.async_step_local() + return await self.async_step_cloud() + + async def async_step_local(self, user_input=None): + """Handle the local step.""" + data_schema = vol.Schema( + {vol.Required(WIFI_SSID): str, vol.Required(WIFI_PSWD): str} + ) + if user_input is None: + return self.async_show_form( + step_id="local", + data_schema=data_schema, + ) + + wifi_ssid = user_input[WIFI_SSID].replace(" ", "") + wifi_pswd = user_input[WIFI_PSWD].replace(" ", "") + configurator = adax_local.AdaxConfig(wifi_ssid, wifi_pswd) + + try: + if not await configurator.configure_device(): + return self.async_show_form( + step_id="local", + data_schema=data_schema, + errors={"base": "cannot_connect"}, + ) + except adax_local.HeaterNotAvailable: + return self.async_abort(reason="heater_not_available") + except adax_local.HeaterNotFound: + return self.async_abort(reason="heater_not_found") + except adax_local.InvalidWifiCred: + return self.async_abort(reason="invalid_auth") + + unique_id = configurator.mac_id + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=unique_id, + data={ + CONF_IP_ADDRESS: configurator.device_ip, + CONF_TOKEN: configurator.access_token, + CONF_UNIQUE_ID: unique_id, + CONNECTION_TYPE: LOCAL, + }, + ) + + async def async_step_cloud( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the cloud step.""" + data_schema = vol.Schema( + {vol.Required(ACCOUNT_ID): int, vol.Required(CONF_PASSWORD): str} + ) + if user_input is None: + return self.async_show_form(step_id="cloud", data_schema=data_schema) + errors = {} await self.async_set_unique_id(user_input[ACCOUNT_ID]) self._abort_if_unique_id_configured() - try: - await validate_input(self.hass, user_input) - except CannotConnect: + account_id = user_input[ACCOUNT_ID] + password = user_input[CONF_PASSWORD].replace(" ", "") + + token = await adax.get_adax_token( + async_get_clientsession(self.hass), account_id, password + ) + if token is None: + _LOGGER.info("Adax: Failed to login to retrieve token") errors["base"] = "cannot_connect" - else: - return self.async_create_entry( - title=user_input[ACCOUNT_ID], data=user_input + return self.async_show_form( + step_id="cloud", + data_schema=data_schema, + errors=errors, ) - return self.async_show_form( - step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + return self.async_create_entry( + title=user_input[ACCOUNT_ID], + data={ + ACCOUNT_ID: account_id, + CONF_PASSWORD: password, + CONNECTION_TYPE: CLOUD, + }, ) - - -class CannotConnect(HomeAssistantError): - """Error to indicate we cannot connect.""" diff --git a/homeassistant/components/adax/const.py b/homeassistant/components/adax/const.py index ecb83f9b0f7..86c627aa130 100644 --- a/homeassistant/components/adax/const.py +++ b/homeassistant/components/adax/const.py @@ -2,4 +2,9 @@ from typing import Final ACCOUNT_ID: Final = "account_id" +CLOUD = "Cloud" +CONNECTION_TYPE = "connection_type" DOMAIN: Final = "adax" +LOCAL = "Local" +WIFI_SSID = "wifi_ssid" +WIFI_PSWD = "wifi_pswd" diff --git a/homeassistant/components/adax/manifest.json b/homeassistant/components/adax/manifest.json index cf5cbbd02a5..75d389e912a 100644 --- a/homeassistant/components/adax/manifest.json +++ b/homeassistant/components/adax/manifest.json @@ -4,10 +4,10 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/adax", "requirements": [ - "adax==0.2.0" + "adax==0.2.0", "Adax-local==0.1.1" ], "codeowners": [ "@danielhiversen" ], - "iot_class": "cloud_polling" + "iot_class": "local_polling" } diff --git a/homeassistant/components/adax/strings.json b/homeassistant/components/adax/strings.json index 213e1f95cf9..0cb60dceac9 100644 --- a/homeassistant/components/adax/strings.json +++ b/homeassistant/components/adax/strings.json @@ -2,6 +2,19 @@ "config": { "step": { "user": { + "data": { + "connection_type": "Select connection type" + }, + "description": "Select connection type. Local requires heaters with bluetooth" + }, + "local": { + "data": { + "wifi_ssid": "Wifi ssid", + "wifi_pswd": "Wifi password" + }, + "description": "Reset the heater by pressing + and OK until display shows 'Reset'. Then press and hold OK button on the heater until the blue led starts blinking before pressing Submit. Configuring heater might take some minutes." + }, + "cloud": { "data": { "account_id": "Account ID", "password": "[%key:common::config_flow::data::password%]" @@ -12,7 +25,10 @@ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "heater_not_available": "Heater not available. Try to reset the heater by pressing + and OK for some seconds.", + "heater_not_found": "Heater not found. Try to move the heater closer to Home Assistant computer.", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" } } } diff --git a/requirements_all.txt b/requirements_all.txt index 3f2e428944f..2b6f4c3e117 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -13,6 +13,9 @@ Adafruit-SHT31==1.0.2 # homeassistant.components.bbb_gpio # Adafruit_BBIO==1.1.1 +# homeassistant.components.adax +Adax-local==0.1.1 + # homeassistant.components.homekit HAP-python==4.3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0d98a4302c0..20e2e297c2a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -6,6 +6,9 @@ # homeassistant.components.aemet AEMET-OpenData==0.2.1 +# homeassistant.components.adax +Adax-local==0.1.1 + # homeassistant.components.homekit HAP-python==4.3.0 diff --git a/tests/components/adax/test_config_flow.py b/tests/components/adax/test_config_flow.py index f9638e52cbf..d35a18cdacc 100644 --- a/tests/components/adax/test_config_flow.py +++ b/tests/components/adax/test_config_flow.py @@ -1,10 +1,21 @@ """Test the Adax config flow.""" from unittest.mock import patch +import adax_local + from homeassistant import config_entries -from homeassistant.components.adax.const import ACCOUNT_ID, DOMAIN +from homeassistant.components.adax.const import ( + ACCOUNT_ID, + CLOUD, + CONNECTION_TYPE, + DOMAIN, + LOCAL, + WIFI_PSWD, + WIFI_SSID, +) from homeassistant.const import CONF_PASSWORD from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import RESULT_TYPE_ABORT, RESULT_TYPE_FORM from tests.common import MockConfigEntry @@ -19,24 +30,33 @@ async def test_form(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["type"] == "form" + assert result["type"] == RESULT_TYPE_FORM assert result["errors"] is None + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONNECTION_TYPE: CLOUD, + }, + ) + assert result2["type"] == RESULT_TYPE_FORM + with patch("adax.get_adax_token", return_value="test_token",), patch( "homeassistant.components.adax.async_setup_entry", return_value=True, ) as mock_setup_entry: - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], TEST_DATA, ) await hass.async_block_till_done() - assert result2["type"] == "create_entry" - assert result2["title"] == TEST_DATA["account_id"] - assert result2["data"] == { - "account_id": TEST_DATA["account_id"], - "password": TEST_DATA["password"], + assert result3["type"] == "create_entry" + assert result3["title"] == TEST_DATA["account_id"] + assert result3["data"] == { + ACCOUNT_ID: TEST_DATA["account_id"], + CONF_PASSWORD: TEST_DATA["password"], + CONNECTION_TYPE: CLOUD, } assert len(mock_setup_entry.mock_calls) == 1 @@ -47,16 +67,24 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_USER} ) + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONNECTION_TYPE: CLOUD, + }, + ) + assert result2["type"] == RESULT_TYPE_FORM + with patch( "adax.get_adax_token", return_value=None, ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], TEST_DATA, ) - assert result2["type"] == "form" - assert result2["errors"] == {"base": "cannot_connect"} + assert result3["type"] == RESULT_TYPE_FORM + assert result3["errors"] == {"base": "cannot_connect"} async def test_flow_entry_already_exists(hass: HomeAssistant) -> None: @@ -69,10 +97,266 @@ async def test_flow_entry_already_exists(hass: HomeAssistant) -> None: ) first_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONNECTION_TYPE: CLOUD, + }, + ) + assert result2["type"] == RESULT_TYPE_FORM + with patch("adax.get_adax_token", return_value="token"): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER}, data=TEST_DATA + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + TEST_DATA, + ) + await hass.async_block_till_done() + + assert result3["type"] == "abort" + assert result3["reason"] == "already_configured" + + +# local API: + + +async def test_local_create_entry(hass): + """Test create entry from user input.""" + 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 + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONNECTION_TYPE: LOCAL, + }, + ) + assert result2["type"] == RESULT_TYPE_FORM + + test_data = { + WIFI_SSID: "ssid", + WIFI_PSWD: "pswd", + } + + with patch( + "homeassistant.components.adax.async_setup_entry", + return_value=True, + ), patch( + "homeassistant.components.adax.config_flow.adax_local.AdaxConfig", autospec=True + ) as mock_client_class: + client = mock_client_class.return_value + client.configure_device.return_value = True + client.device_ip = "192.168.1.4" + client.access_token = "token" + client.mac_id = "8383838" + result = await hass.config_entries.flow.async_configure( + result2["flow_id"], + test_data, + ) + + test_data[CONNECTION_TYPE] = LOCAL + assert result["type"] == "create_entry" + assert result["title"] == "8383838" + assert result["data"] == { + "connection_type": "Local", + "ip_address": "192.168.1.4", + "token": "token", + "unique_id": "8383838", + } + + +async def test_local_flow_entry_already_exists(hass): + """Test user input for config_entry that already exists.""" + + test_data = { + WIFI_SSID: "ssid", + WIFI_PSWD: "pswd", + } + + first_entry = MockConfigEntry( + domain="adax", + data=test_data, + unique_id="8383838", + ) + first_entry.add_to_hass(hass) + + 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 + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONNECTION_TYPE: LOCAL, + }, + ) + assert result2["type"] == RESULT_TYPE_FORM + + test_data = { + WIFI_SSID: "ssid", + WIFI_PSWD: "pswd", + } + + with patch("adax_local.AdaxConfig", autospec=True) as mock_client_class: + client = mock_client_class.return_value + client.configure_device.return_value = True + client.device_ip = "192.168.1.4" + client.access_token = "token" + client.mac_id = "8383838" + + result = await hass.config_entries.flow.async_configure( + result2["flow_id"], + test_data, ) assert result["type"] == "abort" assert result["reason"] == "already_configured" + + +async def test_local_connection_error(hass): + """Test connection error.""" + + 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 + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONNECTION_TYPE: LOCAL, + }, + ) + assert result2["type"] == RESULT_TYPE_FORM + + test_data = { + WIFI_SSID: "ssid", + WIFI_PSWD: "pswd", + } + + with patch( + "homeassistant.components.adax.config_flow.adax_local.AdaxConfig.configure_device", + return_value=False, + ): + result = await hass.config_entries.flow.async_configure( + result2["flow_id"], + test_data, + ) + + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] == {"base": "cannot_connect"} + + +async def test_local_heater_not_available(hass): + """Test connection error.""" + + 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 + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONNECTION_TYPE: LOCAL, + }, + ) + assert result2["type"] == RESULT_TYPE_FORM + + test_data = { + WIFI_SSID: "ssid", + WIFI_PSWD: "pswd", + } + + with patch( + "homeassistant.components.adax.config_flow.adax_local.AdaxConfig.configure_device", + side_effect=adax_local.HeaterNotAvailable, + ): + result = await hass.config_entries.flow.async_configure( + result2["flow_id"], + test_data, + ) + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "heater_not_available" + + +async def test_local_heater_not_found(hass): + """Test connection error.""" + + 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 + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONNECTION_TYPE: LOCAL, + }, + ) + assert result2["type"] == RESULT_TYPE_FORM + + test_data = { + WIFI_SSID: "ssid", + WIFI_PSWD: "pswd", + } + + with patch( + "homeassistant.components.adax.config_flow.adax_local.AdaxConfig.configure_device", + side_effect=adax_local.HeaterNotFound, + ): + result = await hass.config_entries.flow.async_configure( + result2["flow_id"], + test_data, + ) + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "heater_not_found" + + +async def test_local_invalid_wifi_cred(hass): + """Test connection error.""" + + 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 + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONNECTION_TYPE: LOCAL, + }, + ) + assert result2["type"] == RESULT_TYPE_FORM + + test_data = { + WIFI_SSID: "ssid", + WIFI_PSWD: "pswd", + } + + with patch( + "homeassistant.components.adax.config_flow.adax_local.AdaxConfig.configure_device", + side_effect=adax_local.InvalidWifiCred, + ): + result = await hass.config_entries.flow.async_configure( + result2["flow_id"], + test_data, + ) + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "invalid_auth"