From 1935e126bfb8e3155954d66464bf76094da1c18f Mon Sep 17 00:00:00 2001 From: Jafar Atili Date: Sat, 20 May 2023 13:13:32 +0300 Subject: [PATCH] Add new integration to control Electra Smart HVAC devices (#70361) * Added new integration to support Electra Smart (HVAC) * fixes + option to set scan interval * renamed the module to electrasmart and added unittests * added non tested files to .coveragerc * changed the usage from UpdateCoordinator to each entity updates it self * small fixes * increased pypi package version, increased polling timeout to 60 seconds, improved error handling * PARALLEL_UPDATE=1 to prevent multi access to the API * code improvements * aligned with the new HA APIs * fixes * fixes * more * fixes * more * more * handled re-atuh flow * fixed test * removed hvac action * added shabat mode * tests: 100% coverage * ran hassfest * Update homeassistant/components/electrasmart/manifest.json Co-authored-by: Shay Levy * Update homeassistant/components/electrasmart/manifest.json Co-authored-by: Shay Levy * Update homeassistant/components/electrasmart/manifest.json Co-authored-by: Shay Levy * Update homeassistant/components/electrasmart/climate.py Co-authored-by: Shay Levy * address Shay's comments * address Shay's comments * address more comments --------- Co-authored-by: Shay Levy --- .coveragerc | 2 + .strict-typing | 1 + CODEOWNERS | 2 + .../components/electrasmart/__init__.py | 46 +++ .../components/electrasmart/climate.py | 334 ++++++++++++++++++ .../components/electrasmart/config_flow.py | 158 +++++++++ .../components/electrasmart/const.py | 13 + .../components/electrasmart/manifest.json | 9 + .../components/electrasmart/strings.json | 25 ++ .../electrasmart/translations/en.json | 25 ++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + mypy.ini | 10 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/electrasmart/__init__.py | 1 + .../fixtures/generate_token_response.json | 6 + .../fixtures/invalid_otp_response.json | 6 + .../invalid_phone_number_response.json | 6 + .../electrasmart/fixtures/otp_response.json | 11 + .../electrasmart/test_config_flow.py | 164 +++++++++ 21 files changed, 832 insertions(+) create mode 100644 homeassistant/components/electrasmart/__init__.py create mode 100644 homeassistant/components/electrasmart/climate.py create mode 100644 homeassistant/components/electrasmart/config_flow.py create mode 100644 homeassistant/components/electrasmart/const.py create mode 100644 homeassistant/components/electrasmart/manifest.json create mode 100644 homeassistant/components/electrasmart/strings.json create mode 100644 homeassistant/components/electrasmart/translations/en.json create mode 100644 tests/components/electrasmart/__init__.py create mode 100644 tests/components/electrasmart/fixtures/generate_token_response.json create mode 100644 tests/components/electrasmart/fixtures/invalid_otp_response.json create mode 100644 tests/components/electrasmart/fixtures/invalid_phone_number_response.json create mode 100644 tests/components/electrasmart/fixtures/otp_response.json create mode 100644 tests/components/electrasmart/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index 834232c9025..6f3b1b092cf 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1535,6 +1535,8 @@ omit = homeassistant/components/zwave_me/sensor.py homeassistant/components/zwave_me/siren.py homeassistant/components/zwave_me/switch.py + homeassistant/components/electrasmart/climate.py + homeassistant/components/electrasmart/__init__.py [report] # Regexes for lines to exclude from consideration diff --git a/.strict-typing b/.strict-typing index f7297d8e680..fc40996a37b 100644 --- a/.strict-typing +++ b/.strict-typing @@ -105,6 +105,7 @@ homeassistant.components.dormakaba_dkey.* homeassistant.components.dsmr.* homeassistant.components.dunehd.* homeassistant.components.efergy.* +homeassistant.components.electrasmart.* homeassistant.components.elgato.* homeassistant.components.elkm1.* homeassistant.components.emulated_hue.* diff --git a/CODEOWNERS b/CODEOWNERS index c2eba386420..a870c7dea3c 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -309,6 +309,8 @@ build.json @home-assistant/supervisor /homeassistant/components/egardia/ @jeroenterheerdt /homeassistant/components/eight_sleep/ @mezz64 @raman325 /tests/components/eight_sleep/ @mezz64 @raman325 +/homeassistant/components/electrasmart/ @jafar-atili +/tests/components/electrasmart/ @jafar-atili /homeassistant/components/elgato/ @frenck /tests/components/elgato/ @frenck /homeassistant/components/elkm1/ @gwww @bdraco diff --git a/homeassistant/components/electrasmart/__init__.py b/homeassistant/components/electrasmart/__init__.py new file mode 100644 index 00000000000..6fb9c35757f --- /dev/null +++ b/homeassistant/components/electrasmart/__init__.py @@ -0,0 +1,46 @@ +"""The Electra Air Conditioner integration.""" +from __future__ import annotations + +from typing import cast + +from electrasmart.api import ElectraAPI, ElectraApiError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_TOKEN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import CONF_IMEI, DOMAIN + +PLATFORMS: list[Platform] = [Platform.CLIMATE] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Electra Smart Air Conditioner from a config entry.""" + hass.data.setdefault(DOMAIN, {}) + entry.async_on_unload(entry.add_update_listener(update_listener)) + hass.data[DOMAIN][entry.entry_id] = ElectraAPI( + async_get_clientsession(hass), entry.data[CONF_IMEI], entry.data[CONF_TOKEN] + ) + + try: + await cast(ElectraAPI, hass.data[DOMAIN][entry.entry_id]).fetch_devices() + except ElectraApiError as exp: + raise ConfigEntryNotReady(f"Error communicating with API: {exp}") from exp + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok + + +async def update_listener(hass: HomeAssistant, config_entry: ConfigEntry) -> None: + """Update listener.""" + await hass.config_entries.async_reload(config_entry.entry_id) diff --git a/homeassistant/components/electrasmart/climate.py b/homeassistant/components/electrasmart/climate.py new file mode 100644 index 00000000000..361f906133d --- /dev/null +++ b/homeassistant/components/electrasmart/climate.py @@ -0,0 +1,334 @@ +"""Support for the Electra climate.""" + +from __future__ import annotations + +from datetime import timedelta +import logging +import time +from typing import Any + +from electrasmart.api import STATUS_SUCCESS, Attributes, ElectraAPI, ElectraApiError +from electrasmart.device import ElectraAirConditioner, OperationMode +from electrasmart.device.const import MAX_TEMP, MIN_TEMP, Feature + +from homeassistant.components.climate import ( + FAN_AUTO, + FAN_HIGH, + FAN_LOW, + FAN_MEDIUM, + SWING_BOTH, + SWING_HORIZONTAL, + SWING_OFF, + SWING_VERTICAL, + ClimateEntity, + ClimateEntityFeature, + HVACMode, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import ( + API_DELAY, + CONSECUTIVE_FAILURE_THRESHOLD, + DOMAIN, + PRESET_NONE, + PRESET_SHABAT, + SCAN_INTERVAL_SEC, + UNAVAILABLE_THRESH_SEC, +) + +FAN_ELECTRA_TO_HASS = { + OperationMode.FAN_SPEED_AUTO: FAN_AUTO, + OperationMode.FAN_SPEED_LOW: FAN_LOW, + OperationMode.FAN_SPEED_MED: FAN_MEDIUM, + OperationMode.FAN_SPEED_HIGH: FAN_HIGH, +} + +FAN_HASS_TO_ELECTRA = { + FAN_AUTO: OperationMode.FAN_SPEED_AUTO, + FAN_LOW: OperationMode.FAN_SPEED_LOW, + FAN_MEDIUM: OperationMode.FAN_SPEED_MED, + FAN_HIGH: OperationMode.FAN_SPEED_HIGH, +} + +HVAC_MODE_ELECTRA_TO_HASS = { + OperationMode.MODE_COOL: HVACMode.COOL, + OperationMode.MODE_HEAT: HVACMode.HEAT, + OperationMode.MODE_FAN: HVACMode.FAN_ONLY, + OperationMode.MODE_DRY: HVACMode.DRY, + OperationMode.MODE_AUTO: HVACMode.AUTO, +} + +HVAC_MODE_HASS_TO_ELECTRA = { + HVACMode.COOL: OperationMode.MODE_COOL, + HVACMode.HEAT: OperationMode.MODE_HEAT, + HVACMode.FAN_ONLY: OperationMode.MODE_FAN, + HVACMode.DRY: OperationMode.MODE_DRY, + HVACMode.AUTO: OperationMode.MODE_AUTO, +} + +ELECTRA_FAN_MODES = [FAN_AUTO, FAN_HIGH, FAN_MEDIUM, FAN_LOW] +ELECTRA_MODES = [ + HVACMode.OFF, + HVACMode.HEAT, + HVACMode.COOL, + HVACMode.DRY, + HVACMode.FAN_ONLY, + HVACMode.AUTO, +] + +_LOGGER = logging.getLogger(__name__) + + +SCAN_INTERVAL = timedelta(seconds=SCAN_INTERVAL_SEC) +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Add Electra AC devices.""" + api: ElectraAPI = hass.data[DOMAIN][entry.entry_id] + + _LOGGER.debug("Discovered %i Electra devices", len(api.devices)) + async_add_entities( + (ElectraClimateEntity(device, api) for device in api.devices), True + ) + + +class ElectraClimateEntity(ClimateEntity): + """Define an Electra climate.""" + + _attr_fan_modes = ELECTRA_FAN_MODES + _attr_target_temperature_step = 1 + _attr_max_temp = MAX_TEMP + _attr_min_temp = MIN_TEMP + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_hvac_modes = ELECTRA_MODES + + def __init__(self, device: ElectraAirConditioner, api: ElectraAPI) -> None: + """Initialize Electra climate entity.""" + self._api = api + self._electra_ac_device = device + self._attr_name = device.name + self._attr_unique_id = device.mac + self._attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE + ) + + swing_modes: list = [] + if Feature.V_SWING in self._electra_ac_device.features: + swing_modes.append(SWING_VERTICAL) + if Feature.H_SWING in self._electra_ac_device.features: + swing_modes.append(SWING_HORIZONTAL) + + if all(elem in [SWING_HORIZONTAL, SWING_VERTICAL] for elem in swing_modes): + swing_modes.append(SWING_BOTH) + if swing_modes: + swing_modes.append(SWING_OFF) + self._attr_swing_modes = swing_modes + self._attr_supported_features |= ClimateEntityFeature.SWING_MODE + + self._attr_preset_modes = [ + PRESET_NONE, + PRESET_SHABAT, + ] + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._electra_ac_device.mac)}, + name=self.name, + model=self._electra_ac_device.model, + manufacturer=self._electra_ac_device.manufactor, + ) + + # This attribute will be used to mark the time we communicated + # a command to the API + self._last_state_update = 0 + + # count the consecutive update failures, used to print error log + self._consecutive_failures = 0 + self._skip_update = True + self._was_available = True + + _LOGGER.debug("Added %s Electra AC device", self._attr_name) + + @property + def available(self) -> bool: + """Return True if the AC is available.""" + return ( + not self._electra_ac_device.is_disconnected(UNAVAILABLE_THRESH_SEC) + and super().available + ) + + async def async_update(self) -> None: + """Update Electra device.""" + + # if we communicated a change to the API in the last API_DELAY seconds, + # then don't receive any updates as the API takes few seconds + # until it start sending it last recent change + if self._last_state_update and int(time.time()) < ( + self._last_state_update + API_DELAY + ): + _LOGGER.debug("Skipping state update, keeping old values") + return + + self._last_state_update = 0 + + try: + # skip the first update only as we already got the devices with their current state + if self._skip_update: + self._skip_update = False + else: + await self._api.get_last_telemtry(self._electra_ac_device) + + if not self.available: + # show the warning once upon state change + if self._was_available: + _LOGGER.warning( + "Electra AC %s (%s) is not available, check its status in the Electra Smart mobile app", + self.name, + self._electra_ac_device.mac, + ) + self._was_available = False + return + + if not self._was_available: + _LOGGER.info( + "%s (%s) is now available", + self._electra_ac_device.mac, + self.name, + ) + self._was_available = True + + _LOGGER.debug( + "%s (%s) state updated: %s", + self._electra_ac_device.mac, + self.name, + self._electra_ac_device.__dict__, + ) + except ElectraApiError as exp: + self._consecutive_failures += 1 + _LOGGER.warning( + "Failed to get %s state: %s (try #%i since last success), keeping old state", + self.name, + exp, + self._consecutive_failures, + ) + + if self._consecutive_failures >= CONSECUTIVE_FAILURE_THRESHOLD: + raise HomeAssistantError( + f"Failed to get {self.name} state: {exp} for the {self._consecutive_failures} time", + ) from ElectraApiError + + self._consecutive_failures = 0 + self._update_device_attrs() + + async def async_set_fan_mode(self, fan_mode: str) -> None: + """Set AC fan mode.""" + mode = FAN_HASS_TO_ELECTRA[fan_mode] + self._electra_ac_device.set_fan_speed(mode) + await self._async_operate_electra_ac() + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set hvac mode.""" + + if hvac_mode == HVACMode.OFF: + self._electra_ac_device.turn_off() + else: + self._electra_ac_device.set_mode(HVAC_MODE_HASS_TO_ELECTRA[hvac_mode]) + self._electra_ac_device.turn_on() + + await self._async_operate_electra_ac() + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new target temperature.""" + + if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None: + raise ValueError("No target temperature provided") + + self._electra_ac_device.set_temperature(temperature) + await self._async_operate_electra_ac() + + def _update_device_attrs(self) -> None: + self._attr_fan_mode = FAN_ELECTRA_TO_HASS[ + self._electra_ac_device.get_fan_speed() + ] + self._attr_current_temperature = ( + self._electra_ac_device.get_sensor_temperature() + ) + self._attr_target_temperature = self._electra_ac_device.get_temperature() + + self._attr_hvac_mode = ( + HVACMode.OFF + if not self._electra_ac_device.is_on() + else HVAC_MODE_ELECTRA_TO_HASS[self._electra_ac_device.get_mode()] + ) + + if ( + self._electra_ac_device.is_horizontal_swing() + and self._electra_ac_device.is_vertical_swing() + ): + self._attr_swing_mode = SWING_BOTH + elif self._electra_ac_device.is_horizontal_swing(): + self._attr_swing_mode = SWING_HORIZONTAL + elif self._electra_ac_device.is_vertical_swing(): + self._attr_swing_mode = SWING_VERTICAL + else: + self._attr_swing_mode = SWING_OFF + + self._attr_preset_mode = ( + PRESET_SHABAT if self._electra_ac_device.get_shabat_mode() else PRESET_NONE + ) + + async def async_set_swing_mode(self, swing_mode: str) -> None: + """Set AC swing mdde.""" + if swing_mode == SWING_BOTH: + self._electra_ac_device.set_horizontal_swing(True) + self._electra_ac_device.set_vertical_swing(True) + + elif swing_mode == SWING_VERTICAL: + self._electra_ac_device.set_horizontal_swing(False) + self._electra_ac_device.set_vertical_swing(True) + + elif swing_mode == SWING_HORIZONTAL: + self._electra_ac_device.set_horizontal_swing(True) + self._electra_ac_device.set_vertical_swing(False) + else: + self._electra_ac_device.set_horizontal_swing(False) + self._electra_ac_device.set_vertical_swing(False) + + await self._async_operate_electra_ac() + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set Preset mode.""" + if preset_mode == PRESET_SHABAT: + self._electra_ac_device.set_shabat_mode(True) + else: + self._electra_ac_device.set_shabat_mode(False) + + await self._async_operate_electra_ac() + + async def _async_operate_electra_ac(self) -> None: + """Send HVAC parameters to API.""" + + try: + resp = await self._api.set_state(self._electra_ac_device) + except ElectraApiError as exp: + raise HomeAssistantError( + f"Error communicating with Electra API: {exp}" + ) from exp + + if not ( + resp[Attributes.STATUS] == STATUS_SUCCESS + and resp[Attributes.DATA][Attributes.RES] == STATUS_SUCCESS + ): + self._async_write_ha_state() + raise HomeAssistantError(f"Failed to update {self.name}, error: {resp}") + + self._update_device_attrs() + self._last_state_update = int(time.time()) + self._async_write_ha_state() diff --git a/homeassistant/components/electrasmart/config_flow.py b/homeassistant/components/electrasmart/config_flow.py new file mode 100644 index 00000000000..946a9f2854d --- /dev/null +++ b/homeassistant/components/electrasmart/config_flow.py @@ -0,0 +1,158 @@ +"""Config flow for Electra Air Conditioner integration.""" +from __future__ import annotations + +import logging +from typing import Any + +from electrasmart.api import STATUS_SUCCESS, Attributes, ElectraAPI, ElectraApiError +from electrasmart.api.utils import generate_imei +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_TOKEN +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import CONF_IMEI, CONF_OTP, CONF_PHONE_NUMBER, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Electra Air Conditioner.""" + + VERSION = 1 + + def __init__(self) -> None: + """Device settings.""" + self._phone_number: str | None = None + self._description_placeholders = None + self._otp: str | None = None + self._imei: str | None = None + self._token: str | None = None + self._api: ElectraAPI | None = None + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + + if not self._api: + self._api = ElectraAPI(async_get_clientsession(self.hass)) + + errors: dict[str, Any] = {} + + if user_input is None: + return self._show_setup_form(user_input, errors, "user") + + return await self._validate_phone_number(user_input) + + def _show_setup_form( + self, + user_input: dict[str, str] | None = None, + errors: dict[str, str] | None = None, + step_id: str = "user", + ) -> FlowResult: + """Show the setup form to the user.""" + if user_input is None: + user_input = {} + + if step_id == "user": + schema = { + vol.Required( + CONF_PHONE_NUMBER, default=user_input.get(CONF_PHONE_NUMBER, "") + ): str + } + else: + schema = {vol.Required(CONF_OTP, default=user_input.get(CONF_OTP, "")): str} + + return self.async_show_form( + step_id=step_id, + data_schema=vol.Schema(schema), + errors=errors or {}, + description_placeholders=self._description_placeholders, + ) + + async def _validate_phone_number(self, user_input: dict[str, str]) -> FlowResult: + """Check if config is valid and create entry if so.""" + + self._phone_number = user_input[CONF_PHONE_NUMBER] + self._imei = generate_imei() + + # Check if already configured + if self.unique_id is None: + await self.async_set_unique_id(self._phone_number) + self._abort_if_unique_id_configured() + + assert isinstance(self._api, ElectraAPI) + + try: + resp = await self._api.generate_new_token(self._phone_number, self._imei) + except ElectraApiError as exp: + _LOGGER.error("Failed to connect to API: %s", exp) + return self._show_setup_form(user_input, {"base": "cannot_connect"}, "user") + + if resp[Attributes.STATUS] == STATUS_SUCCESS: + if resp[Attributes.DATA][Attributes.RES] != STATUS_SUCCESS: + return self._show_setup_form( + user_input, {CONF_PHONE_NUMBER: "invalid_phone_number"}, "user" + ) + + return await self.async_step_one_time_password() + + async def _validate_one_time_password( + self, user_input: dict[str, str] + ) -> FlowResult: + self._otp = user_input[CONF_OTP] + + assert isinstance(self._api, ElectraAPI) + assert isinstance(self._imei, str) + assert isinstance(self._phone_number, str) + assert isinstance(self._otp, str) + + try: + resp = await self._api.validate_one_time_password( + self._otp, self._imei, self._phone_number + ) + except ElectraApiError as exp: + _LOGGER.error("Failed to connect to API: %s", exp) + return self._show_setup_form( + user_input, {"base": "cannot_connect"}, CONF_OTP + ) + + if resp[Attributes.DATA][Attributes.RES] == STATUS_SUCCESS: + self._token = resp[Attributes.DATA][Attributes.TOKEN] + + data = { + CONF_TOKEN: self._token, + CONF_IMEI: self._imei, + CONF_PHONE_NUMBER: self._phone_number, + } + return self.async_create_entry(title=self._phone_number, data=data) + return self._show_setup_form(user_input, {CONF_OTP: "invalid_auth"}, CONF_OTP) + + async def async_step_one_time_password( + self, + user_input: dict[str, Any] | None = None, + errors: dict[str, str] | None = None, + ) -> FlowResult: + """Ask the verification code to the user.""" + if errors is None: + errors = {} + + if user_input is None: + return await self._show_otp_form(errors) + + return await self._validate_one_time_password(user_input) + + async def _show_otp_form( + self, + errors: dict[str, str] | None = None, + ) -> FlowResult: + """Show the verification_code form to the user.""" + + return self.async_show_form( + step_id=CONF_OTP, + data_schema=vol.Schema({vol.Required(CONF_OTP): str}), + errors=errors or {}, + ) diff --git a/homeassistant/components/electrasmart/const.py b/homeassistant/components/electrasmart/const.py new file mode 100644 index 00000000000..1a48dd3c463 --- /dev/null +++ b/homeassistant/components/electrasmart/const.py @@ -0,0 +1,13 @@ +"""Constants for the Electra Air Conditioner integration.""" + +DOMAIN = "electrasmart" + +CONF_PHONE_NUMBER = "phone_number" +CONF_OTP = "one_time_password" +CONF_IMEI = "imei" +SCAN_INTERVAL_SEC = 30 +API_DELAY = 5 +CONSECUTIVE_FAILURE_THRESHOLD = 4 +UNAVAILABLE_THRESH_SEC = 120 +PRESET_NONE = "None" +PRESET_SHABAT = "Shabat" diff --git a/homeassistant/components/electrasmart/manifest.json b/homeassistant/components/electrasmart/manifest.json new file mode 100644 index 00000000000..a2a3f928eeb --- /dev/null +++ b/homeassistant/components/electrasmart/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "electrasmart", + "name": "Electra Smart", + "codeowners": ["@jafar-atili"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/electrasmart", + "iot_class": "cloud_polling", + "requirements": ["pyelectra==1.2.0"] +} diff --git a/homeassistant/components/electrasmart/strings.json b/homeassistant/components/electrasmart/strings.json new file mode 100644 index 00000000000..06c7dfd6bed --- /dev/null +++ b/homeassistant/components/electrasmart/strings.json @@ -0,0 +1,25 @@ +{ + "config": { + "step": { + "user": { + "data": { + "phone_number": "Phone Number" + } + }, + "one_time_password": { + "data": { + "one_time_password": "One Time Password" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]", + "invalid_phone_number": "Either wrong phone number or unregistered user" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + } +} diff --git a/homeassistant/components/electrasmart/translations/en.json b/homeassistant/components/electrasmart/translations/en.json new file mode 100644 index 00000000000..c6afd624540 --- /dev/null +++ b/homeassistant/components/electrasmart/translations/en.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "already_configured": "Phone number already configured" + }, + "error": { + "cannot_connect": "Failed to connect to Electra API", + "invalid_auth": "Wrong one time password key", + "invalid_phone_number": "Either wrong phone number or unregistered user", + "unknown": "Unexpected error" + }, + "step": { + "user": { + "data": { + "phone_number": "Phone Number (eg. 0501234567)" + } + }, + "one_time_password": { + "data": { + "one_time_password": "One Time Password (OTP)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 48c4051bb84..1b76aa2b56d 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -113,6 +113,7 @@ FLOWS = { "edl21", "efergy", "eight_sleep", + "electrasmart", "elgato", "elkm1", "elmax", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index b3dc06926ca..c69ac70e7d6 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -1299,6 +1299,12 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "electrasmart": { + "name": "Electra Smart", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "elgato": { "name": "Elgato", "integrations": { diff --git a/mypy.ini b/mypy.ini index b4155232ff1..fd5e87bd499 100644 --- a/mypy.ini +++ b/mypy.ini @@ -812,6 +812,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.electrasmart.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.elgato.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 1edeab6218e..4e4e7755a75 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1607,6 +1607,9 @@ pyefergy==22.1.1 # homeassistant.components.eight_sleep pyeight==0.3.2 +# homeassistant.components.electrasmart +pyelectra==1.2.0 + # homeassistant.components.emby pyemby==1.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f2e3928e664..8b64595dbee 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1177,6 +1177,9 @@ pyefergy==22.1.1 # homeassistant.components.eight_sleep pyeight==0.3.2 +# homeassistant.components.electrasmart +pyelectra==1.2.0 + # homeassistant.components.everlights pyeverlights==0.1.0 diff --git a/tests/components/electrasmart/__init__.py b/tests/components/electrasmart/__init__.py new file mode 100644 index 00000000000..cb2db8dcded --- /dev/null +++ b/tests/components/electrasmart/__init__.py @@ -0,0 +1 @@ +"""Tests for the Electra Air Conditioner integration.""" diff --git a/tests/components/electrasmart/fixtures/generate_token_response.json b/tests/components/electrasmart/fixtures/generate_token_response.json new file mode 100644 index 00000000000..43de435e564 --- /dev/null +++ b/tests/components/electrasmart/fixtures/generate_token_response.json @@ -0,0 +1,6 @@ +{ + "id": 99, + "status": 0, + "desc": "None", + "data": { "res": 0, "res_desc": "None" } +} diff --git a/tests/components/electrasmart/fixtures/invalid_otp_response.json b/tests/components/electrasmart/fixtures/invalid_otp_response.json new file mode 100644 index 00000000000..2df84b0703b --- /dev/null +++ b/tests/components/electrasmart/fixtures/invalid_otp_response.json @@ -0,0 +1,6 @@ +{ + "id": 99, + "status": 1, + "desc": "None", + "data": { "res": 100, "res_desc": "None" } +} diff --git a/tests/components/electrasmart/fixtures/invalid_phone_number_response.json b/tests/components/electrasmart/fixtures/invalid_phone_number_response.json new file mode 100644 index 00000000000..9ff9f395356 --- /dev/null +++ b/tests/components/electrasmart/fixtures/invalid_phone_number_response.json @@ -0,0 +1,6 @@ +{ + "id": 99, + "status": 0, + "desc": "None", + "data": { "res": 100, "res_desc": "None" } +} diff --git a/tests/components/electrasmart/fixtures/otp_response.json b/tests/components/electrasmart/fixtures/otp_response.json new file mode 100644 index 00000000000..0e9623b616a --- /dev/null +++ b/tests/components/electrasmart/fixtures/otp_response.json @@ -0,0 +1,11 @@ +{ + "id": 99, + "status": 0, + "desc": "None", + "data": { + "token": "ec7a0db6c1f148ca8c0f48aabb5f8150", + "sid": "bd6f11f947244e5d9612eba89e91112b", + "res": 0, + "res_desc": "None" + } +} diff --git a/tests/components/electrasmart/test_config_flow.py b/tests/components/electrasmart/test_config_flow.py new file mode 100644 index 00000000000..f53bea3e96c --- /dev/null +++ b/tests/components/electrasmart/test_config_flow.py @@ -0,0 +1,164 @@ +"""Test the Electra Smart config flow.""" +from json import loads +from unittest.mock import patch + +from homeassistant import config_entries +from homeassistant.components.electrasmart.config_flow import ElectraApiError +from homeassistant.components.electrasmart.const import ( + CONF_OTP, + CONF_PHONE_NUMBER, + DOMAIN, +) +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import load_fixture + + +async def test_form(hass: HomeAssistant): + """Test user config.""" + + mock_generate_token = loads(load_fixture("generate_token_response.json", DOMAIN)) + with patch( + "electrasmart.api.ElectraAPI.generate_new_token", + return_value=mock_generate_token, + ): + # test with required + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data=None, + ) + + assert result["step_id"] == "user" + + # test with required + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={CONF_PHONE_NUMBER: "0521234567"}, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == CONF_OTP + + +async def test_one_time_password(hass: HomeAssistant): + """Test one time password.""" + + mock_generate_token = loads(load_fixture("generate_token_response.json", DOMAIN)) + mock_otp_response = loads(load_fixture("otp_response.json", DOMAIN)) + with patch( + "electrasmart.api.ElectraAPI.generate_new_token", + return_value=mock_generate_token, + ), patch( + "electrasmart.api.ElectraAPI.validate_one_time_password", + return_value=mock_otp_response, + ), patch( + "electrasmart.api.ElectraAPI.fetch_devices", return_value=[] + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={CONF_PHONE_NUMBER: "0521234567", CONF_OTP: "1234"}, + ) + + # test with required + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_OTP: "1234"} + ) + assert result["type"] == FlowResultType.CREATE_ENTRY + + +async def test_one_time_password_api_error(hass: HomeAssistant): + """Test one time password.""" + mock_generate_token = loads(load_fixture("generate_token_response.json", DOMAIN)) + with patch( + "electrasmart.api.ElectraAPI.generate_new_token", + return_value=mock_generate_token, + ), patch( + "electrasmart.api.ElectraAPI.validate_one_time_password", + side_effect=ElectraApiError, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={CONF_PHONE_NUMBER: "0521234567"}, + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_OTP: "1234"} + ) + + assert result["type"] == FlowResultType.FORM + + +async def test_cannot_connect(hass: HomeAssistant): + """Test cannot connect.""" + + with patch( + "electrasmart.api.ElectraAPI.generate_new_token", + side_effect=ElectraApiError, + ): + # test with required + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={CONF_PHONE_NUMBER: "0521234567"}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "cannot_connect"} + + +async def test_invalid_phone_number(hass: HomeAssistant): + """Test invalid phone number.""" + + mock_invalid_phone_number_response = loads( + load_fixture("invalid_phone_number_response.json", DOMAIN) + ) + + with patch( + "electrasmart.api.ElectraAPI.generate_new_token", + return_value=mock_invalid_phone_number_response, + ): + # test with required + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={CONF_PHONE_NUMBER: "0521234567"}, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"phone_number": "invalid_phone_number"} + + +async def test_invalid_auth(hass: HomeAssistant): + """Test invalid auth.""" + + mock_generate_token_response = loads( + load_fixture("generate_token_response.json", DOMAIN) + ) + mock_invalid_otp_response = loads(load_fixture("invalid_otp_response.json", DOMAIN)) + + with patch( + "electrasmart.api.ElectraAPI.generate_new_token", + return_value=mock_generate_token_response, + ), patch( + "electrasmart.api.ElectraAPI.validate_one_time_password", + return_value=mock_invalid_otp_response, + ): + # test with required + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={CONF_PHONE_NUMBER: "0521234567", CONF_OTP: "1234"}, + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_OTP: "1234"} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == CONF_OTP + assert result["errors"] == {CONF_OTP: "invalid_auth"}