diff --git a/.coveragerc b/.coveragerc index 84577e99197..49c2cb98424 100644 --- a/.coveragerc +++ b/.coveragerc @@ -20,6 +20,8 @@ omit = homeassistant/components/acmeda/helpers.py homeassistant/components/acmeda/hub.py homeassistant/components/acmeda/sensor.py + homeassistant/components/adax/__init__.py + homeassistant/components/adax/climate.py homeassistant/components/adguard/__init__.py homeassistant/components/adguard/const.py homeassistant/components/adguard/sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index acd2b6d44c3..e5a9bcd5823 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -22,6 +22,7 @@ homeassistant/scripts/check_config.py @kellerza homeassistant/components/abode/* @shred86 homeassistant/components/accuweather/* @bieniu homeassistant/components/acmeda/* @atmurray +homeassistant/components/adax/* @danielhiversen homeassistant/components/adguard/* @frenck homeassistant/components/advantage_air/* @Bre77 homeassistant/components/aemet/* @noltari diff --git a/homeassistant/components/adax/__init__.py b/homeassistant/components/adax/__init__.py new file mode 100644 index 00000000000..0a14648af26 --- /dev/null +++ b/homeassistant/components/adax/__init__.py @@ -0,0 +1,18 @@ +"""The Adax integration.""" +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +PLATFORMS = ["climate"] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Adax from a config entry.""" + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/adax/climate.py b/homeassistant/components/adax/climate.py new file mode 100644 index 00000000000..74e973ba6d5 --- /dev/null +++ b/homeassistant/components/adax/climate.py @@ -0,0 +1,152 @@ +"""Support for Adax wifi-enabled home heaters.""" +from __future__ import annotations + +import logging +from typing import Any + +from adax import Adax + +from homeassistant.components.climate import ClimateEntity +from homeassistant.components.climate.const import ( + HVAC_MODE_HEAT, + HVAC_MODE_OFF, + SUPPORT_TARGET_TEMPERATURE, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + ATTR_TEMPERATURE, + CONF_PASSWORD, + PRECISION_WHOLE, + TEMP_CELSIUS, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import ACCOUNT_ID + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Adax thermostat with config flow.""" + adax_data_handler = Adax( + entry.data[ACCOUNT_ID], + entry.data[CONF_PASSWORD], + websession=async_get_clientsession(hass), + ) + + async_add_entities( + AdaxDevice(room, adax_data_handler) + for room in await adax_data_handler.get_rooms() + ) + + +class AdaxDevice(ClimateEntity): + """Representation of a heater.""" + + def __init__(self, heater_data: dict[str, Any], adax_data_handler: Adax) -> None: + """Initialize the heater.""" + self._heater_data = heater_data + self._adax_data_handler = adax_data_handler + + @property + def supported_features(self) -> int: + """Return the list of supported features.""" + return SUPPORT_TARGET_TEMPERATURE + + @property + def unique_id(self) -> str: + """Return a unique ID.""" + return f"{self._heater_data['homeId']}_{self._heater_data['id']}" + + @property + def name(self) -> str: + """Return the name of the device, if any.""" + return self._heater_data["name"] + + @property + def hvac_mode(self) -> str: + """Return hvac operation ie. heat, cool mode.""" + if self._heater_data["heatingEnabled"]: + return HVAC_MODE_HEAT + return HVAC_MODE_OFF + + @property + def icon(self) -> str: + """Return nice icon for heater.""" + if self.hvac_mode == HVAC_MODE_HEAT: + return "mdi:radiator" + return "mdi:radiator-off" + + @property + def hvac_modes(self) -> list[str]: + """Return the list of available hvac operation modes.""" + return [HVAC_MODE_HEAT, HVAC_MODE_OFF] + + async def async_set_hvac_mode(self, hvac_mode: str) -> None: + """Set hvac mode.""" + if hvac_mode == HVAC_MODE_HEAT: + temperature = max( + self.min_temp, self._heater_data.get("targetTemperature", self.min_temp) + ) + await self._adax_data_handler.set_room_target_temperature( + self._heater_data["id"], temperature, True + ) + elif hvac_mode == HVAC_MODE_OFF: + await self._adax_data_handler.set_room_target_temperature( + self._heater_data["id"], self.min_temp, False + ) + else: + return + await self._adax_data_handler.update() + + @property + def temperature_unit(self) -> str: + """Return the unit of measurement which this device uses.""" + return TEMP_CELSIUS + + @property + def min_temp(self) -> int: + """Return the minimum temperature.""" + return 5 + + @property + def max_temp(self) -> int: + """Return the maximum temperature.""" + return 35 + + @property + def current_temperature(self) -> float | None: + """Return the current temperature.""" + return self._heater_data.get("temperature") + + @property + def target_temperature(self) -> int | None: + """Return the temperature we try to reach.""" + return self._heater_data.get("targetTemperature") + + @property + def target_temperature_step(self) -> int: + """Return the supported step of target temperature.""" + return PRECISION_WHOLE + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new target temperature.""" + temperature = kwargs.get(ATTR_TEMPERATURE) + if temperature is None: + return + await self._adax_data_handler.set_room_target_temperature( + self._heater_data["id"], temperature, True + ) + + async def async_update(self) -> None: + """Get the latest data.""" + for room in await self._adax_data_handler.get_rooms(): + if room["id"] == self._heater_data["id"]: + self._heater_data = room + return diff --git a/homeassistant/components/adax/config_flow.py b/homeassistant/components/adax/config_flow.py new file mode 100644 index 00000000000..166278ef48d --- /dev/null +++ b/homeassistant/components/adax/config_flow.py @@ -0,0 +1,71 @@ +"""Config flow for Adax integration.""" +from __future__ import annotations + +import logging +from typing import Any + +import adax +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_PASSWORD +from homeassistant.core import HomeAssistant +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} +) + + +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 + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Adax.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + if user_input is None: + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA + ) + + errors = {} + + try: + self._async_abort_entries_match({ACCOUNT_ID: user_input[ACCOUNT_ID]}) + await validate_input(self.hass, user_input) + except CannotConnect: + 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="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) + + +class CannotConnect(HomeAssistantError): + """Error to indicate we cannot connect.""" diff --git a/homeassistant/components/adax/const.py b/homeassistant/components/adax/const.py new file mode 100644 index 00000000000..ecb83f9b0f7 --- /dev/null +++ b/homeassistant/components/adax/const.py @@ -0,0 +1,5 @@ +"""Constants for the Adax integration.""" +from typing import Final + +ACCOUNT_ID: Final = "account_id" +DOMAIN: Final = "adax" diff --git a/homeassistant/components/adax/manifest.json b/homeassistant/components/adax/manifest.json new file mode 100644 index 00000000000..36106290ed6 --- /dev/null +++ b/homeassistant/components/adax/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "adax", + "name": "Adax", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/adax", + "requirements": [ + "adax==0.0.1" + ], + "codeowners": [ + "@danielhiversen" + ], + "iot_class": "cloud_polling" +} diff --git a/homeassistant/components/adax/strings.json b/homeassistant/components/adax/strings.json new file mode 100644 index 00000000000..0f7aac83f5a --- /dev/null +++ b/homeassistant/components/adax/strings.json @@ -0,0 +1,20 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "account_id": "Account ID", + "password": "[%key:common::config_flow::data::password%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + } +} diff --git a/homeassistant/components/adax/translations/en.json b/homeassistant/components/adax/translations/en.json new file mode 100644 index 00000000000..a5a204c93f8 --- /dev/null +++ b/homeassistant/components/adax/translations/en.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured" + }, + "error": { + "cannot_connect": "Failed to connect", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "step": { + "user": { + "data": { + "host": "Host", + "password": "Password", + "account_id": "Account ID" + } + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 45a339ebed1..1321c01b27d 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -9,6 +9,7 @@ FLOWS = [ "abode", "accuweather", "acmeda", + "adax", "adguard", "advantage_air", "aemet", diff --git a/requirements_all.txt b/requirements_all.txt index a0b2fa1d554..0559e927aec 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -104,6 +104,9 @@ adafruit-circuitpython-dht==3.6.0 # homeassistant.components.mcp23017 adafruit-circuitpython-mcp230xx==2.2.2 +# homeassistant.components.adax +adax==0.0.1 + # homeassistant.components.androidtv adb-shell[async]==0.3.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 36cfed2598e..80f25c59e31 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -47,6 +47,9 @@ abodepy==1.2.0 # homeassistant.components.accuweather accuweather==0.2.0 +# homeassistant.components.adax +adax==0.0.1 + # homeassistant.components.androidtv adb-shell[async]==0.3.4 diff --git a/tests/components/adax/__init__.py b/tests/components/adax/__init__.py new file mode 100644 index 00000000000..54a72856a85 --- /dev/null +++ b/tests/components/adax/__init__.py @@ -0,0 +1 @@ +"""Tests for the Adax integration.""" diff --git a/tests/components/adax/test_config_flow.py b/tests/components/adax/test_config_flow.py new file mode 100644 index 00000000000..f9638e52cbf --- /dev/null +++ b/tests/components/adax/test_config_flow.py @@ -0,0 +1,78 @@ +"""Test the Adax config flow.""" +from unittest.mock import patch + +from homeassistant import config_entries +from homeassistant.components.adax.const import ACCOUNT_ID, DOMAIN +from homeassistant.const import CONF_PASSWORD +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +TEST_DATA = { + ACCOUNT_ID: 12345, + CONF_PASSWORD: "pswd", +} + + +async def test_form(hass: HomeAssistant) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["errors"] is None + + 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"], + 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 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( + "adax.get_adax_token", + return_value=None, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + TEST_DATA, + ) + assert result2["type"] == "form" + assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_flow_entry_already_exists(hass: HomeAssistant) -> None: + """Test user input for config_entry that already exists.""" + + first_entry = MockConfigEntry( + domain="adax", + data=TEST_DATA, + unique_id=TEST_DATA[ACCOUNT_ID], + ) + first_entry.add_to_hass(hass) + + 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 + ) + + assert result["type"] == "abort" + assert result["reason"] == "already_configured"