From 93fd6df340568c6c9e550525feda11755d2bfc27 Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Thu, 15 Jul 2021 00:09:23 +0200 Subject: [PATCH] Squashed commit of the following: commit 4a771610ea7567b5c6dc89a9a7978a0f2cd8da6f Author: Mick Vleeshouwer Date: Wed Jul 14 14:52:07 2021 -0700 Adds basic support for AtlanticElectricalHeaterWithAdjustableTemperatureSetpoint (#362) commit 93a02ff9e45531e4bea381cd6db05bd9df55699e Author: Mick Vleeshouwer Date: Wed Jul 14 14:30:49 2021 -0700 Add support for AtlanticElectricalTowelDryer (#423) * Add scaffold for feature/AtlanticElectricalTowelDryer * Update presets and other features * First clean up * remove comments commit a4709cdf492d6b3673005da007d8aba03ca80c0b Author: Mick Vleeshouwer Date: Wed Jul 14 14:18:00 2021 -0700 Adjust Atlantic Electrical Heater to new standards and fix issues (#454) * Adjust Atlantic Electrical Heater to new standards and fix issues * Add comfort mode * Add missing extra comfort presets commit 65de1316145d7e05ec063fae6d3e0b6cc81919ce Author: Mick Vleeshouwer Date: Wed Jul 14 13:41:35 2021 -0700 Add more information regarding Model and Manufacturer (#474) commit 76d566c09202fc4b875052f9d8d55b4aa85a759a Author: Mick Vleeshouwer Date: Wed Jul 14 11:56:06 2021 -0700 Improve reauth, Config Flow and error handling (#452) commit 847ee292f1f540d78ee246a90125e714a2d2e39d Author: Thibaut Date: Wed Jul 14 19:09:43 2021 +0200 Fix is_opening/closing for RTS devices (#464) Co-authored-by: Mick Vleeshouwer commit 560db42944f41cf032b7e335a88b86e0dd31d498 Author: Mick Vleeshouwer Date: Wed Jul 14 10:01:12 2021 -0700 Update pyhoma and require latest Home Assistant version (#473) commit 242e21de9da69674589cc0ea4e074a500783e8fd Author: Mick Vleeshouwer Date: Wed Jul 14 08:01:55 2021 -0700 Currently stale issues are closed too soon (#470) commit c467aa4e0002d75417e39e635260a60f81923874 Author: Thibaut Date: Tue Jun 29 17:13:52 2021 +0200 Fix is_opening/is_closing for RTS covers (#463) commit 60f076601bb3980a7179dae465e25a400f856960 Author: Thibaut Date: Mon Jun 21 09:17:18 2021 +0200 Support is_opening/closing when moving position (#460) commit 39f485ebcc73a13d35121061e481403791a8aeed Author: Thibaut Date: Mon May 31 17:27:48 2021 +0200 Remove setPosition (#455) --- .github/workflows/stale.yml | 2 +- custom_components/tahoma/__init__.py | 16 +- custom_components/tahoma/climate.py | 17 +- .../atlantic_electrical_heater.py | 60 +++-- ...er_with_adjustable_temperature_setpoint.py | 239 ++++++++++++++++++ .../atlantic_electrical_towel_dryer.py | 141 +++++++++++ custom_components/tahoma/config_flow.py | 68 ++++- custom_components/tahoma/const.py | 2 + custom_components/tahoma/coordinator.py | 3 +- custom_components/tahoma/cover.py | 62 +++-- custom_components/tahoma/manifest.json | 2 +- custom_components/tahoma/strings.json | 2 +- custom_components/tahoma/tahoma_entity.py | 15 +- custom_components/tahoma/translations/en.json | 10 +- custom_components/tahoma/translations/fr.json | 4 +- custom_components/tahoma/translations/nl.json | 10 +- hacs.json | 13 +- requirements.txt | 2 +- requirements_dev.txt | 2 +- requirements_test.txt | 2 +- tests/conftest.py | 7 +- tests/test_config_flow.py | 154 +++++++---- 22 files changed, 680 insertions(+), 153 deletions(-) create mode 100644 custom_components/tahoma/climate_devices/atlantic_electrical_heater_with_adjustable_temperature_setpoint.py create mode 100644 custom_components/tahoma/climate_devices/atlantic_electrical_towel_dryer.py diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index b6e15bedc..074b2773d 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -23,6 +23,6 @@ jobs: This issue now has been marked as stale and will be closed if no further activity occurs. Thank you for your contributions.' days-before-stale: 30 - days-before-close: 5 + days-before-close: 30 stale-issue-label: 'no-issue-activity' exempt-issue-labels: 'work-in-progress,blocked,help wanted,under investigation' diff --git a/custom_components/tahoma/__init__.py b/custom_components/tahoma/__init__.py index c3377b2b2..69e7ebbf4 100644 --- a/custom_components/tahoma/__init__.py +++ b/custom_components/tahoma/__init__.py @@ -10,7 +10,7 @@ from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_EXCLUDE, CONF_PASSWORD, CONF_SOURCE, CONF_USERNAME from homeassistant.core import HomeAssistant, ServiceCall -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import ( config_validation as cv, device_registry as dr, @@ -117,18 +117,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): client.get_places(), ] devices, scenarios, gateways, places = await asyncio.gather(*tasks) - except BadCredentialsException: - _LOGGER.error("Invalid authentication.") - return False + except BadCredentialsException as exception: + raise ConfigEntryAuthFailed from exception except TooManyRequestsException as exception: - _LOGGER.error("Too many requests, try again later.") - raise ConfigEntryNotReady from exception + raise ConfigEntryNotReady("Too many requests, try again later") from exception except (TimeoutError, ClientError, ServerDisconnectedError) as exception: - _LOGGER.error("Failed to connect.") - raise ConfigEntryNotReady from exception + raise ConfigEntryNotReady("Failed to connect") from exception except MaintenanceException as exception: - _LOGGER.error("Server is down for maintenance.") - raise ConfigEntryNotReady from exception + raise ConfigEntryNotReady("Server is down for maintenance") from exception except Exception as exception: # pylint: disable=broad-except _LOGGER.exception(exception) return False diff --git a/custom_components/tahoma/climate.py b/custom_components/tahoma/climate.py index 236d48463..9ccf856da 100644 --- a/custom_components/tahoma/climate.py +++ b/custom_components/tahoma/climate.py @@ -1,8 +1,13 @@ """Support for TaHoma climate devices.""" - from homeassistant.components.climate import DOMAIN as CLIMATE from .climate_devices.atlantic_electrical_heater import AtlanticElectricalHeater +from .climate_devices.atlantic_electrical_heater_with_adjustable_temperature_setpoint import ( + AtlanticElectricalHeaterWithAdjustableTemperatureSetpoint, +) +from .climate_devices.atlantic_electrical_towel_dryer import ( + AtlanticElectricalTowelDryer, +) from .climate_devices.atlantic_pass_apc_heating_and_cooling_zone import ( AtlanticPassAPCHeatingAndCoolingZone, ) @@ -20,15 +25,17 @@ TYPE = { "AtlanticElectricalHeater": AtlanticElectricalHeater, + "AtlanticElectricalHeaterWithAdjustableTemperatureSetpoint": AtlanticElectricalHeaterWithAdjustableTemperatureSetpoint, + "AtlanticElectricalTowelDryer": AtlanticElectricalTowelDryer, + "AtlanticPassAPCDHW": AtlanticPassAPCDHW, "AtlanticPassAPCHeatingAndCoolingZone": AtlanticPassAPCHeatingAndCoolingZone, "AtlanticPassAPCZoneControl": AtlanticPassAPCZoneControl, - "HitachiAirToWaterHeatingZone": HitachiAirToWaterHeatingZone, - "SomfyThermostat": SomfyThermostat, "DimmerExteriorHeating": DimmerExteriorHeating, - "StatelessExteriorHeating": StatelessExteriorHeating, - "AtlanticPassAPCDHW": AtlanticPassAPCDHW, "EvoHomeController": EvoHomeController, "HeatingSetPoint": HeatingSetPoint, + "HitachiAirToWaterHeatingZone": HitachiAirToWaterHeatingZone, + "SomfyThermostat": SomfyThermostat, + "StatelessExteriorHeating": StatelessExteriorHeating, } SERVICE_CLIMATE_MY_POSITION = "set_climate_my_position" diff --git a/custom_components/tahoma/climate_devices/atlantic_electrical_heater.py b/custom_components/tahoma/climate_devices/atlantic_electrical_heater.py index 07136ebe5..cb8a5f8ae 100644 --- a/custom_components/tahoma/climate_devices/atlantic_electrical_heater.py +++ b/custom_components/tahoma/climate_devices/atlantic_electrical_heater.py @@ -1,4 +1,4 @@ -"""Support for Atlantic Electrical Heater IO controller.""" +"""Support for Atlantic Electrical Heater.""" from typing import List, Optional from homeassistant.components.climate import ( @@ -18,26 +18,34 @@ COMMAND_SET_HEATING_LEVEL = "setHeatingLevel" +CORE_ON_OFF_STATE = "core:OnOffState" IO_TARGET_HEATING_LEVEL_STATE = "io:TargetHeatingLevelState" -PRESET_FREEZE = "Freeze" -PRESET_FROST_PROTECTION = "frostprotection" -PRESET_OFF = "off" - -MAP_PRESET_MODES = { - PRESET_OFF: PRESET_NONE, - PRESET_FROST_PROTECTION: PRESET_FREEZE, - PRESET_ECO: PRESET_ECO, - PRESET_COMFORT: PRESET_COMFORT, +PRESET_COMFORT1 = "comfort-1" +PRESET_COMFORT2 = "comfort-2" +PRESET_FROST_PROTECTION = "frost_protection" + +TAHOMA_TO_PRESET_MODES = { + "off": PRESET_NONE, + "frostprotection": PRESET_FROST_PROTECTION, + "eco": PRESET_ECO, + "comfort": PRESET_COMFORT, + "comfort-1": PRESET_COMFORT1, + "comfort-2": PRESET_COMFORT2, } -MAP_REVERSE_PRESET_MODES = {v: k for k, v in MAP_PRESET_MODES.items()} +PRESET_MODES_TO_TAHOMA = {v: k for k, v in TAHOMA_TO_PRESET_MODES.items()} -MAP_HVAC_MODES = {HVAC_MODE_HEAT: PRESET_COMFORT, HVAC_MODE_OFF: PRESET_OFF} +TAHOMA_TO_HVAC_MODES = { + "on": HVAC_MODE_HEAT, + "comfort": HVAC_MODE_HEAT, + "off": HVAC_MODE_OFF, +} +HVAC_MODES_TO_TAHOMA = {v: k for k, v in TAHOMA_TO_HVAC_MODES.items()} class AtlanticElectricalHeater(TahomaEntity, ClimateEntity): - """Representation of TaHoma IO Atlantic Electrical Heater.""" + """Representation of Atlantic Electrical Heater.""" @property def temperature_unit(self) -> str: @@ -52,35 +60,31 @@ def supported_features(self) -> int: @property def hvac_mode(self) -> str: """Return hvac operation ie. heat, cool mode.""" - return HVAC_MODE_OFF if self.preset_mode == PRESET_NONE else HVAC_MODE_HEAT + return TAHOMA_TO_HVAC_MODES[self.select_state(*CORE_ON_OFF_STATE)] @property def hvac_modes(self) -> List[str]: """Return the list of available hvac operation modes.""" - return [*MAP_HVAC_MODES] + return [*HVAC_MODES_TO_TAHOMA] + + async def async_set_hvac_mode(self, hvac_mode: str) -> None: + """Set new target hvac mode.""" + await self.async_execute_command( + COMMAND_SET_HEATING_LEVEL, HVAC_MODES_TO_TAHOMA[hvac_mode] + ) @property def preset_mode(self) -> Optional[str]: """Return the current preset mode, e.g., home, away, temp.""" - return MAP_PRESET_MODES[self.select_state(IO_TARGET_HEATING_LEVEL_STATE)] + return TAHOMA_TO_PRESET_MODES[self.select_state(IO_TARGET_HEATING_LEVEL_STATE)] @property def preset_modes(self) -> Optional[List[str]]: """Return a list of available preset modes.""" - return [PRESET_NONE, PRESET_FREEZE, PRESET_ECO, PRESET_COMFORT] - - async def async_set_hvac_mode(self, hvac_mode: str) -> None: - """Set new target hvac mode.""" - await self.async_execute_command( - COMMAND_SET_HEATING_LEVEL, MAP_HVAC_MODES[hvac_mode] - ) + return [*PRESET_MODES_TO_TAHOMA] async def async_set_preset_mode(self, preset_mode: str) -> None: """Set new preset mode.""" await self.async_execute_command( - COMMAND_SET_HEATING_LEVEL, MAP_REVERSE_PRESET_MODES[preset_mode] + COMMAND_SET_HEATING_LEVEL, PRESET_MODES_TO_TAHOMA[preset_mode] ) - - async def async_turn_off(self) -> None: - """Turn off the device.""" - await self.async_execute_command(COMMAND_SET_HEATING_LEVEL, PRESET_OFF) diff --git a/custom_components/tahoma/climate_devices/atlantic_electrical_heater_with_adjustable_temperature_setpoint.py b/custom_components/tahoma/climate_devices/atlantic_electrical_heater_with_adjustable_temperature_setpoint.py new file mode 100644 index 000000000..d2ea2e6ed --- /dev/null +++ b/custom_components/tahoma/climate_devices/atlantic_electrical_heater_with_adjustable_temperature_setpoint.py @@ -0,0 +1,239 @@ +"""Support for Atlantic Electrical Heater (With Adjustable Temperature Setpoint).""" +import logging +from typing import List, Optional + +from homeassistant.components.climate import ( + SUPPORT_PRESET_MODE, + SUPPORT_TARGET_TEMPERATURE, + ClimateEntity, +) +from homeassistant.components.climate.const import ( + HVAC_MODE_AUTO, + HVAC_MODE_HEAT, + HVAC_MODE_OFF, + PRESET_BOOST, + PRESET_COMFORT, + PRESET_ECO, + PRESET_NONE, +) +from homeassistant.const import ( + ATTR_TEMPERATURE, + EVENT_HOMEASSISTANT_START, + STATE_UNKNOWN, + TEMP_CELSIUS, +) +from homeassistant.core import callback +from homeassistant.helpers.event import async_track_state_change + +from ..coordinator import TahomaDataUpdateCoordinator +from ..tahoma_entity import TahomaEntity + +_LOGGER = logging.getLogger(__name__) + +COMMAND_SET_HEATING_LEVEL = "setHeatingLevel" +COMMAND_SET_TARGET_TEMPERATURE = "setTargetTemperature" +COMMAND_SET_OPERATING_MODE = "setOperatingMode" +COMMAND_OFF = "off" + +CORE_OPERATING_MODE_STATE = "core:OperatingModeState" +CORE_TARGET_TEMPERATURE_STATE = "core:TargetTemperatureState" +CORE_ON_OFF_STATE = "core:OnOffState" +IO_TARGET_HEATING_LEVEL_STATE = "io:TargetHeatingLevelState" + +PRESET_AUTO = "auto" +PRESET_COMFORT1 = "comfort-1" +PRESET_COMFORT2 = "comfort-2" +PRESET_FROST_PROTECTION = "frost_protection" +PRESET_PROG = "prog" + +PRESET_STATE_ECO = "eco" +PRESET_STATE_BOOST = "boost" +PRESET_STATE_COMFORT = "comfort" + + +# Map TaHoma presets to Home Assistant presets +TAHOMA_TO_PRESET_MODE = { + "off": PRESET_NONE, + "frostprotection": PRESET_FROST_PROTECTION, + "eco": PRESET_ECO, + "comfort": PRESET_COMFORT, + "comfort-1": PRESET_COMFORT1, + "comfort-2": PRESET_COMFORT2, + "auto": PRESET_AUTO, + "boost": PRESET_BOOST, + "internal": PRESET_PROG, +} + +PRESET_MODE_TO_TAHOMA = {v: k for k, v in TAHOMA_TO_PRESET_MODE.items()} + +# Map TaHoma HVAC modes to Home Assistant HVAC modes +TAHOMA_TO_HVAC_MODE = { + "on": HVAC_MODE_HEAT, + "off": HVAC_MODE_OFF, + "auto": HVAC_MODE_AUTO, + "basic": HVAC_MODE_HEAT, + "standby": HVAC_MODE_OFF, + "internal": HVAC_MODE_AUTO, +} + +HVAC_MODE_TO_TAHOMA = {v: k for k, v in TAHOMA_TO_HVAC_MODE.items()} + + +class AtlanticElectricalHeaterWithAdjustableTemperatureSetpoint( + TahomaEntity, ClimateEntity +): + """Representation of Atlantic Electrical Heater (With Adjustable Temperature Setpoint).""" + + def __init__(self, device_url: str, coordinator: TahomaDataUpdateCoordinator): + """Init method.""" + super().__init__(device_url, coordinator) + + self._temp_sensor_entity_id = None + self._current_temperature = None + + async def async_added_to_hass(self): + """Register temperature sensor after added to hass.""" + await super().async_added_to_hass() + + # Only the AtlanticElectricarHeater WithAdjustableTemperatureSetpoint has a separate temperature sensor + if ( + self.device.widget + != "AtlanticElectricalHeaterWithAdjustableTemperatureSetpoint" + ): + return + + base_url = self.device.deviceurl.split("#", 1)[0] + entity_registry = await self.hass.helpers.entity_registry.async_get_registry() + self._temp_sensor_entity_id = next( + ( + entity_id + for entity_id, entry in entity_registry.entities.items() + if entry.unique_id == f"{base_url}#2" + ), + None, + ) + + if self._temp_sensor_entity_id: + async_track_state_change( + self.hass, self._temp_sensor_entity_id, self._async_temp_sensor_changed + ) + + else: + _LOGGER.warning( + "Temperature sensor could not be found for entity %s", self.name + ) + + @callback + def _async_startup(event): + """Init on startup.""" + if self._temp_sensor_entity_id: + temp_sensor_state = self.hass.states.get(self._temp_sensor_entity_id) + if temp_sensor_state and temp_sensor_state.state != STATE_UNKNOWN: + self.update_temp(temp_sensor_state) + + self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, _async_startup) + + self.schedule_update_ha_state(True) + + async def _async_temp_sensor_changed(self, entity_id, old_state, new_state) -> None: + """Handle temperature changes.""" + if new_state is None or old_state == new_state: + return + + self.update_temp(new_state) + self.schedule_update_ha_state() + + @callback + def update_temp(self, state): + """Update thermostat with latest state from sensor.""" + if state is None or state.state == STATE_UNKNOWN: + return + + try: + self._current_temperature = float(state.state) + except ValueError as ex: + _LOGGER.error("Unable to update from sensor: %s", ex) + + @property + def temperature_unit(self) -> str: + """Return the unit of measurement used by the platform.""" + return TEMP_CELSIUS + + @property + def supported_features(self) -> int: + """Return the list of supported features.""" + supported_features = 0 + + if self.has_command(COMMAND_SET_HEATING_LEVEL): + supported_features |= SUPPORT_PRESET_MODE + + if self.has_command(COMMAND_SET_TARGET_TEMPERATURE): + supported_features |= SUPPORT_TARGET_TEMPERATURE + + return supported_features + + @property + def hvac_modes(self) -> List[str]: + """Return the list of available hvac operation modes.""" + return [*HVAC_MODE_TO_TAHOMA] + + @property + def hvac_mode(self) -> str: + """Return hvac operation ie. heat, cool mode.""" + if CORE_OPERATING_MODE_STATE in self.device.states: + return TAHOMA_TO_HVAC_MODE[self.select_state(CORE_OPERATING_MODE_STATE)] + if CORE_ON_OFF_STATE in self.device.states: + return TAHOMA_TO_HVAC_MODE[self.select_state(CORE_ON_OFF_STATE)] + + async def async_set_hvac_mode(self, hvac_mode: str) -> None: + """Set new target hvac mode.""" + if CORE_OPERATING_MODE_STATE in self.device.states: + await self.async_execute_command( + COMMAND_SET_OPERATING_MODE, HVAC_MODE_TO_TAHOMA[hvac_mode] + ) + else: + if hvac_mode == HVAC_MODE_OFF: + await self.async_execute_command( + COMMAND_OFF, + ) + else: + await self.async_execute_command( + COMMAND_SET_HEATING_LEVEL, PRESET_STATE_COMFORT + ) + + @property + def preset_modes(self) -> Optional[List[str]]: + """Return a list of available preset modes.""" + return [*PRESET_MODE_TO_TAHOMA] + + @property + def preset_mode(self) -> Optional[str]: + """Return the current preset mode, e.g., home, away, temp.""" + return TAHOMA_TO_PRESET_MODE[self.select_state(IO_TARGET_HEATING_LEVEL_STATE)] + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set new preset mode.""" + if preset_mode == PRESET_AUTO or preset_mode == PRESET_PROG: + await self.async_execute_command( + COMMAND_SET_OPERATING_MODE, PRESET_MODE_TO_TAHOMA[preset_mode] + ) + else: + await self.async_execute_command( + COMMAND_SET_HEATING_LEVEL, PRESET_MODE_TO_TAHOMA[preset_mode] + ) + + @property + def target_temperature(self) -> None: + """Return the temperature.""" + if CORE_TARGET_TEMPERATURE_STATE in self.device.states: + return self.select_state(CORE_TARGET_TEMPERATURE_STATE) + + @property + def current_temperature(self): + """Return current temperature.""" + return self._current_temperature + + async def async_set_temperature(self, **kwargs) -> None: + """Set new temperature.""" + temperature = kwargs.get(ATTR_TEMPERATURE) + await self.async_execute_command(COMMAND_SET_TARGET_TEMPERATURE, temperature) diff --git a/custom_components/tahoma/climate_devices/atlantic_electrical_towel_dryer.py b/custom_components/tahoma/climate_devices/atlantic_electrical_towel_dryer.py new file mode 100644 index 000000000..529ad2283 --- /dev/null +++ b/custom_components/tahoma/climate_devices/atlantic_electrical_towel_dryer.py @@ -0,0 +1,141 @@ +"""Support for Atlantic Electrical Towel Dryer.""" +import logging +from typing import List, Optional + +from homeassistant.components.climate import ( + SUPPORT_PRESET_MODE, + SUPPORT_TARGET_TEMPERATURE, + ClimateEntity, +) +from homeassistant.components.climate.const import ( + HVAC_MODE_AUTO, + HVAC_MODE_HEAT, + HVAC_MODE_OFF, + PRESET_NONE, +) +from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS + +from ..tahoma_entity import TahomaEntity + +_LOGGER = logging.getLogger(__name__) + +COMMAND_SET_TARGET_TEMPERATURE = "setTargetTemperature" +COMMAND_SET_DEROGATED_TARGET_TEMPERATURE = "setDerogatedTargetTemperature" +COMMAND_SET_TOWEL_DRYER_OPERATING_MODE = "setTowelDryerOperatingMode" +COMMAND_SET_TOWEL_DRYER_TEMPORARY_STATE = "setTowelDryerTemporaryState" + +CORE_COMFORT_ROOM_TEMPERATURE_STATE = "core:ComfortRoomTemperatureState" +CORE_OPERATING_MODE_STATE = "core:OperatingModeState" +CORE_TARGET_TEMPERATURE_STATE = "core:TargetTemperatureState" +CORE_ON_OFF_STATE = "core:OnOffState" +IO_TARGET_HEATING_LEVEL_STATE = "io:TargetHeatingLevelState" +IO_TOWEL_DRYER_TEMPORARY_STATE_STATE = "io:TowelDryerTemporaryStateState" +IO_EFFECTIVE_TEMPERATURE_SETPOINT_STATE = "io:EffectiveTemperatureSetpointState" + +PRESET_BOOST = "boost" +PRESET_DRYING = "drying" +PRESET_FROST_PROTECTION = "frost_protection" + +PRESET_STATE_FROST_PROTECTION = "frostprotection" +PRESET_STATE_OFF = "off" +PRESET_STATE_ECO = "eco" +PRESET_STATE_BOOST = "boost" +PRESET_STATE_COMFORT = "comfort" +PRESET_STATE_COMFORT1 = "comfort-1" +PRESET_STATE_COMFORT2 = "comfort-2" + +# Map Home Assistant presets to TaHoma presets +PRESET_MODE_TO_TAHOMA = { + PRESET_BOOST: "boost", + PRESET_DRYING: "drying", + PRESET_NONE: "permanentHeating", +} + +TAHOMA_TO_PRESET_MODE = {v: k for k, v in PRESET_MODE_TO_TAHOMA.items()} + +# Map TaHoma HVAC modes to Home Assistant HVAC modes +TAHOMA_TO_HVAC_MODE = { + "external": HVAC_MODE_HEAT, # manu + "standby": HVAC_MODE_OFF, + "internal": HVAC_MODE_AUTO, # prog +} + +HVAC_MODE_TO_TAHOMA = {v: k for k, v in TAHOMA_TO_HVAC_MODE.items()} + + +class AtlanticElectricalTowelDryer(TahomaEntity, ClimateEntity): + """Representation of Atlantic Electrical Towel Dryer.""" + + @property + def temperature_unit(self) -> str: + """Return the unit of measurement used by the platform.""" + return TEMP_CELSIUS + + @property + def supported_features(self) -> int: + """Return the list of supported features.""" + return SUPPORT_PRESET_MODE | SUPPORT_TARGET_TEMPERATURE + + @property + def hvac_modes(self) -> List[str]: + """Return the list of available hvac operation modes.""" + return [*HVAC_MODE_TO_TAHOMA] + + @property + def hvac_mode(self) -> str: + """Return hvac operation ie. heat, cool mode.""" + if CORE_OPERATING_MODE_STATE in self.device.states: + return TAHOMA_TO_HVAC_MODE[self.select_state(CORE_OPERATING_MODE_STATE)] + + if CORE_ON_OFF_STATE in self.device.states: + return TAHOMA_TO_HVAC_MODE[self.select_state(CORE_ON_OFF_STATE)] + + async def async_set_hvac_mode(self, hvac_mode: str) -> None: + """Set new target hvac mode.""" + await self.async_execute_command( + COMMAND_SET_TOWEL_DRYER_OPERATING_MODE, HVAC_MODE_TO_TAHOMA[hvac_mode] + ) + + @property + def preset_modes(self) -> Optional[List[str]]: + """Return a list of available preset modes.""" + return [*PRESET_MODE_TO_TAHOMA] + + @property + def preset_mode(self) -> Optional[str]: + """Return the current preset mode, e.g., home, away, temp.""" + return TAHOMA_TO_PRESET_MODE[ + self.select_state(IO_TOWEL_DRYER_TEMPORARY_STATE_STATE) + ] + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set new preset mode.""" + await self.async_execute_command( + COMMAND_SET_TOWEL_DRYER_TEMPORARY_STATE, PRESET_MODE_TO_TAHOMA[preset_mode] + ) + + @property + def target_temperature(self) -> None: + """Return the temperature.""" + if self.hvac_mode == HVAC_MODE_AUTO: + return self.select_state(IO_EFFECTIVE_TEMPERATURE_SETPOINT_STATE) + else: + return self.select_state(CORE_TARGET_TEMPERATURE_STATE) + + @property + def current_temperature(self): + """Return current temperature.""" + return self.select_state(CORE_COMFORT_ROOM_TEMPERATURE_STATE) + + async def async_set_temperature(self, **kwargs) -> None: + """Set new temperature.""" + temperature = kwargs.get(ATTR_TEMPERATURE) + + if self.hvac_mode == HVAC_MODE_AUTO: + await self.async_execute_command( + COMMAND_SET_DEROGATED_TARGET_TEMPERATURE, temperature + ) + else: + await self.async_execute_command( + COMMAND_SET_TARGET_TEMPERATURE, temperature + ) diff --git a/custom_components/tahoma/config_flow.py b/custom_components/tahoma/config_flow.py index 6182cf58c..ce2a2b1b1 100644 --- a/custom_components/tahoma/config_flow.py +++ b/custom_components/tahoma/config_flow.py @@ -5,6 +5,7 @@ from homeassistant import config_entries from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import callback +from homeassistant.data_entry_flow import AbortFlow from homeassistant.helpers import config_validation as cv from pyhoma.client import TahomaClient from pyhoma.exceptions import ( @@ -26,14 +27,6 @@ _LOGGER = logging.getLogger(__name__) -DATA_SCHEMA = vol.Schema( - { - vol.Required(CONF_USERNAME): str, - vol.Required(CONF_PASSWORD): str, - vol.Required(CONF_HUB, default=DEFAULT_HUB): vol.In(SUPPORTED_ENDPOINTS.keys()), - } -) - class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for Somfy TaHoma.""" @@ -47,6 +40,12 @@ def async_get_options_flow(config_entry): """Handle the flow.""" return OptionsFlowHandler(config_entry) + def __init__(self): + """Start the Overkiz config flow.""" + self._reauth_entry = None + self._default_username = None + self._default_hub = DEFAULT_HUB + async def async_validate_input(self, user_input): """Validate user credentials.""" username = user_input.get(CONF_USERNAME) @@ -57,18 +56,37 @@ async def async_validate_input(self, user_input): async with TahomaClient(username, password, api_url=endpoint) as client: await client.login() - return self.async_create_entry( - title=username, - data=user_input, + + # Set first gateway as unique id + gateways = await client.get_gateways() + if gateways: + gateway_id = gateways[0].id + await self.async_set_unique_id(gateway_id) + + # Create new config entry + if ( + self._reauth_entry is None + or self._reauth_entry.unique_id != self.unique_id + ): + self._abort_if_unique_id_configured() + return self.async_create_entry(title=username, data=user_input) + + # Modify existing entry in reauth scenario + self.hass.config_entries.async_update_entry( + self._reauth_entry, data=user_input ) + await self.hass.config_entries.async_reload(self._reauth_entry.entry_id) + + return self.async_abort(reason="reauth_successful") + async def async_step_user(self, user_input=None): """Handle the initial step via config flow.""" errors = {} if user_input: - await self.async_set_unique_id(user_input.get(CONF_USERNAME)) - self._abort_if_unique_id_configured() + self._default_username = user_input[CONF_USERNAME] + self._default_hub = user_input[CONF_HUB] try: return await self.async_validate_input(user_input) @@ -80,14 +98,36 @@ async def async_step_user(self, user_input=None): errors["base"] = "cannot_connect" except MaintenanceException: errors["base"] = "server_in_maintenance" + except AbortFlow: + raise except Exception as exception: # pylint: disable=broad-except errors["base"] = "unknown" _LOGGER.exception(exception) return self.async_show_form( - step_id="user", data_schema=DATA_SCHEMA, errors=errors + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_USERNAME, default=self._default_username): str, + vol.Required(CONF_PASSWORD): str, + vol.Required(CONF_HUB, default=self._default_hub): vol.In( + SUPPORTED_ENDPOINTS.keys() + ), + } + ), + errors=errors, ) + async def async_step_reauth(self, user_input=None): + """Perform reauth if the user credentials have changed.""" + self._reauth_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + self._default_username = user_input[CONF_USERNAME] + self._default_hub = user_input[CONF_HUB] + + return await self.async_step_user() + async def async_step_import(self, import_config: dict): """Handle the initial step via YAML configuration.""" if not import_config: diff --git a/custom_components/tahoma/const.py b/custom_components/tahoma/const.py index bfb7f2a42..cfcbdc2fc 100644 --- a/custom_components/tahoma/const.py +++ b/custom_components/tahoma/const.py @@ -48,6 +48,8 @@ "AirSensor": SENSOR, "Alarm": ALARM_CONTROL_PANEL, "AtlanticElectricalHeater": CLIMATE, # widgetName, uiClass is HeatingSystem (not supported) + "AtlanticElectricalHeaterWithAdjustableTemperatureSetpoint": CLIMATE, # widgetName, uiClass is HeatingSystem (not supported) + "AtlanticElectricalTowelDryer": CLIMATE, # widgetName, uiClass is HeatingSystem (not supported) "AtlanticPassAPCDHW": CLIMATE, # widgetName, uiClass is WaterHeatingSystem (not supported) "AtlanticPassAPCHeatingAndCoolingZone": CLIMATE, # widgetName, uiClass is HeatingSystem (not supported) "AtlanticPassAPCZoneControl": CLIMATE, # widgetName, uiClass is HeatingSystem (not supported) diff --git a/custom_components/tahoma/coordinator.py b/custom_components/tahoma/coordinator.py index 57db96d1e..fe34fb776 100644 --- a/custom_components/tahoma/coordinator.py +++ b/custom_components/tahoma/coordinator.py @@ -6,6 +6,7 @@ from aiohttp import ServerDisconnectedError from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers import device_registry from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from pyhoma.client import TahomaClient @@ -66,7 +67,7 @@ async def _async_update_data(self) -> Dict[str, Device]: try: events = await self.client.fetch_events() except BadCredentialsException as exception: - raise UpdateFailed("Invalid authentication.") from exception + raise ConfigEntryAuthFailed() from exception except TooManyRequestsException as exception: raise UpdateFailed("Too many requests, try again later.") from exception except MaintenanceException as exception: diff --git a/custom_components/tahoma/cover.py b/custom_components/tahoma/cover.py index 75ff4d8f2..602a5a7ab 100644 --- a/custom_components/tahoma/cover.py +++ b/custom_components/tahoma/cover.py @@ -1,6 +1,4 @@ """Support for TaHoma cover - shutters etc.""" -import logging - from homeassistant.components.cover import ( ATTR_POSITION, ATTR_TILT_POSITION, @@ -28,8 +26,6 @@ from .const import DOMAIN from .tahoma_entity import TahomaEntity -_LOGGER = logging.getLogger(__name__) - ATTR_OBSTRUCTION_DETECTED = "obstruction-detected" COMMAND_CYCLE = "cycle" @@ -41,10 +37,9 @@ COMMAND_OPEN = "open" COMMAND_OPEN_SLATS = "openSlats" COMMAND_SET_CLOSURE = "setClosure" +COMMAND_SET_CLOSURE_AND_LINEAR_SPEED = "setClosureAndLinearSpeed" COMMAND_SET_DEPLOYMENT = "setDeployment" COMMAND_SET_ORIENTATION = "setOrientation" -COMMAND_SET_POSITION = "setPosition" -COMMAND_SET_POSITION_AND_LINEAR_SPEED = "setPositionAndLinearSpeed" COMMAND_STOP = "stop" COMMAND_STOP_IDENTIFY = "stopIdentify" COMMAND_UNDEPLOY = "undeploy" @@ -56,16 +51,14 @@ COMMANDS_OPEN_TILT = [COMMAND_OPEN_SLATS] COMMANDS_CLOSE = [COMMAND_CLOSE, COMMAND_DOWN, COMMAND_CYCLE] COMMANDS_CLOSE_TILT = [COMMAND_CLOSE_SLATS] -COMMANDS_SET_POSITION = [ - COMMAND_SET_POSITION, - COMMAND_SET_CLOSURE, -] + COMMANDS_SET_TILT_POSITION = [COMMAND_SET_ORIENTATION] CORE_CLOSURE_STATE = "core:ClosureState" CORE_CLOSURE_OR_ROCKER_POSITION_STATE = "core:ClosureOrRockerPositionState" CORE_DEPLOYMENT_STATE = "core:DeploymentState" CORE_MEMORIZED_1_POSITION_STATE = "core:Memorized1PositionState" +CORE_MOVING_STATE = "core:MovingState" CORE_OPEN_CLOSED_PARTIAL_STATE = "core:OpenClosedPartialState" CORE_OPEN_CLOSED_STATE = "core:OpenClosedState" CORE_OPEN_CLOSED_UNKNOWN_STATE = "core:OpenClosedUnknownState" @@ -153,7 +146,6 @@ def current_cover_position(self): position = self.select_state( CORE_CLOSURE_STATE, - CORE_TARGET_CLOSURE_STATE, CORE_CLOSURE_OR_ROCKER_POSITION_STATE, ) @@ -181,16 +173,14 @@ async def async_set_cover_position(self, **kwargs): await self.async_execute_command(COMMAND_SET_DEPLOYMENT, position) else: position = 100 - kwargs.get(ATTR_POSITION, 0) - await self.async_execute_command( - self.select_command(*COMMANDS_SET_POSITION), position - ) + await self.async_execute_command(COMMAND_SET_CLOSURE, position) async def async_set_cover_position_low_speed(self, **kwargs): """Move the cover to a specific position with a low speed.""" position = 100 - kwargs.get(ATTR_POSITION, 0) await self.async_execute_command( - COMMAND_SET_POSITION_AND_LINEAR_SPEED, position, "lowspeed" + COMMAND_SET_CLOSURE_AND_LINEAR_SPEED, position, "lowspeed" ) async def async_set_cover_tilt_position(self, **kwargs): @@ -269,7 +259,7 @@ async def async_close_cover_tilt(self, **_): async def async_stop_cover(self, **_): """Stop the cover.""" await self.async_cancel_or_stop_cover( - COMMANDS_OPEN + COMMANDS_SET_POSITION + COMMANDS_CLOSE, + COMMANDS_OPEN + [COMMAND_SET_CLOSURE] + COMMANDS_CLOSE, COMMANDS_STOP, ) @@ -327,19 +317,51 @@ async def async_my(self, **_): @property def is_opening(self): """Return if the cover is opening or not.""" - return any( + + if self.assumed_state: + return None + + if any( execution.get("deviceurl") == self.device.deviceurl and execution.get("command_name") in COMMANDS_OPEN + COMMANDS_OPEN_TILT for execution in self.coordinator.executions.values() + ): + return True + + is_moving = self.device.states.get(CORE_MOVING_STATE) + current_closure = self.device.states.get(CORE_CLOSURE_STATE) + target_closure = self.device.states.get(CORE_TARGET_CLOSURE_STATE) + return ( + is_moving + and is_moving.value + and current_closure + and target_closure + and current_closure.value > target_closure.value ) @property def is_closing(self): """Return if the cover is closing or not.""" - return any( + + if self.assumed_state: + return None + + if any( execution.get("deviceurl") == self.device.deviceurl and execution.get("command_name") in COMMANDS_CLOSE + COMMANDS_CLOSE_TILT for execution in self.coordinator.executions.values() + ): + return True + + is_moving = self.device.states.get(CORE_MOVING_STATE) + current_closure = self.device.states.get(CORE_CLOSURE_STATE) + target_closure = self.device.states.get(CORE_TARGET_CLOSURE_STATE) + return ( + is_moving + and is_moving.value + and current_closure + and target_closure + and current_closure.value < target_closure.value ) @property @@ -370,7 +392,7 @@ def supported_features(self): if self.has_command(*COMMANDS_SET_TILT_POSITION): supported_features |= SUPPORT_SET_TILT_POSITION - if self.has_command(*COMMANDS_SET_POSITION) or self.has_command( + if self.has_command(COMMAND_SET_CLOSURE) or self.has_command( COMMAND_SET_DEPLOYMENT ): supported_features |= SUPPORT_SET_POSITION @@ -384,7 +406,7 @@ def supported_features(self): if self.has_command(*COMMANDS_CLOSE) or self.has_command(COMMAND_UNDEPLOY): supported_features |= SUPPORT_CLOSE - if self.has_command(COMMAND_SET_POSITION_AND_LINEAR_SPEED): + if self.has_command(COMMAND_SET_CLOSURE_AND_LINEAR_SPEED): supported_features |= SUPPORT_COVER_POSITION_LOW_SPEED if self.has_command(COMMAND_MY): diff --git a/custom_components/tahoma/manifest.json b/custom_components/tahoma/manifest.json index d27d9835d..0b0e7eae3 100644 --- a/custom_components/tahoma/manifest.json +++ b/custom_components/tahoma/manifest.json @@ -5,7 +5,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/tahoma", "requirements": [ - "pyhoma==0.5.15" + "pyhoma==0.5.16" ], "codeowners": [ "@philklei", diff --git a/custom_components/tahoma/strings.json b/custom_components/tahoma/strings.json index 0a661ef64..4e171bbf5 100644 --- a/custom_components/tahoma/strings.json +++ b/custom_components/tahoma/strings.json @@ -18,7 +18,7 @@ "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" } }, "options": { diff --git a/custom_components/tahoma/tahoma_entity.py b/custom_components/tahoma/tahoma_entity.py index 24a5e7b09..aea34174d 100644 --- a/custom_components/tahoma/tahoma_entity.py +++ b/custom_components/tahoma/tahoma_entity.py @@ -15,6 +15,7 @@ CORE_AVAILABILITY_STATE = "core:AvailabilityState" CORE_BATTERY_STATE = "core:BatteryState" +CORE_MANUFACTURER = "core:Manufacturer" CORE_MANUFACTURER_NAME_STATE = "core:ManufacturerNameState" CORE_MODEL_STATE = "core:ModelState" CORE_PRODUCT_MODEL_NAME_STATE = "core:ProductModelNameState" @@ -22,6 +23,8 @@ CORE_SENSOR_DEFECT_STATE = "core:SensorDefectState" CORE_STATUS_STATE = "core:StatusState" +IO_MODEL_STATE = "io:ModelState" + STATE_AVAILABLE = "available" STATE_BATTERY_FULL = "full" STATE_BATTERY_NORMAL = "normal" @@ -71,7 +74,7 @@ def unique_id(self) -> str: @property def assumed_state(self) -> bool: """Return True if unable to access real state of the entity.""" - return self.device.states is None or len(self.device.states) == 0 + return not self.device.states @property def device_state_attributes(self) -> Dict[str, Any]: @@ -111,9 +114,15 @@ def device_info(self) -> Dict[str, Any]: "identifiers": {(DOMAIN, self.base_device_url)}, } - manufacturer = self.select_state(CORE_MANUFACTURER_NAME_STATE) or "Somfy" + manufacturer = ( + self.select_attribute(CORE_MANUFACTURER) + or self.select_state(CORE_MANUFACTURER_NAME_STATE) + or "Somfy" + ) model = ( - self.select_state(CORE_MODEL_STATE, CORE_PRODUCT_MODEL_NAME_STATE) + self.select_state( + CORE_MODEL_STATE, CORE_PRODUCT_MODEL_NAME_STATE, IO_MODEL_STATE + ) or self.device.widget ) diff --git a/custom_components/tahoma/translations/en.json b/custom_components/tahoma/translations/en.json index 8dba0cb7e..987524a0a 100644 --- a/custom_components/tahoma/translations/en.json +++ b/custom_components/tahoma/translations/en.json @@ -11,14 +11,14 @@ } }, "error": { - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "cannot_connect": "Failed to connect", + "invalid_auth": "Invalid authentication", + "server_in_maintenance": "Server is down for maintenance.", "too_many_requests": "Too many requests, try again later.", - "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "server_in_maintenance": "Server is down for maintenance", - "unknown": "[%key:common::config_flow::error::unknown%]" + "unknown": "Unexpected error" }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + "already_configured": "Account is already configured" } }, "options": { diff --git a/custom_components/tahoma/translations/fr.json b/custom_components/tahoma/translations/fr.json index a25995c7b..8c8163ff6 100644 --- a/custom_components/tahoma/translations/fr.json +++ b/custom_components/tahoma/translations/fr.json @@ -12,10 +12,10 @@ }, "error": { "cannot_connect": "Connexion impossible", - "too_many_requests": "Trop de requêtes, veuillez réessayer plus tard.", + "too_many_requests": "Trop de requêtes, veuillez réessayer plus tard", "invalid_auth": "Mot de passe ou nom d'utilisateur incorrect", "server_in_maintenance": "Le serveur est en cours de maintenance", - "unknown": "Une erreur inconnue est survenue." + "unknown": "Une erreur inconnue est survenue" }, "abort": { "already_configured": "Votre compte a déjà été ajouté pour cette intégration." diff --git a/custom_components/tahoma/translations/nl.json b/custom_components/tahoma/translations/nl.json index 36739b42c..097116c07 100644 --- a/custom_components/tahoma/translations/nl.json +++ b/custom_components/tahoma/translations/nl.json @@ -11,14 +11,14 @@ } }, "error": { - "too_many_requests": "Te veel verzoeken, probeer het later opnieuw.", - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "too_many_requests": "Te veel verzoeken, probeer het later opnieuw", + "cannot_connect": "Kan geen verbinding maken", + "invalid_auth": "Ongeldige authenticatie", "server_in_maintenance": "De server is offline voor onderhoud", - "unknown": "[%key:common::config_flow::error::unknown%]" + "unknown": "Onverwachte fout" }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + "already_configured": "Account is al geconfigureerd" } }, "options": { diff --git a/hacs.json b/hacs.json index dfddfda07..820d74eca 100644 --- a/hacs.json +++ b/hacs.json @@ -1,7 +1,14 @@ { "name": "Somfy TaHoma", - "domains": ["cover", "light", "lock", "sensor", "switch", "climate"], - "homeassistant": "0.115.0", + "domains": [ + "cover", + "light", + "lock", + "sensor", + "switch", + "climate" + ], + "homeassistant": "2021.7.0", "render_readme": "true", "iot_class": "Cloud Polling" -} +} \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 31f8f3aa6..f6a4ae5d7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1 @@ -pyhoma==0.5.15 \ No newline at end of file +pyhoma==0.5.16 \ No newline at end of file diff --git a/requirements_dev.txt b/requirements_dev.txt index fbfec9a6e..f5e6d8b03 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -1,2 +1,2 @@ -r requirements.txt -homeassistant==2021.4.0b4 \ No newline at end of file +homeassistant==2021.7.0b0 \ No newline at end of file diff --git a/requirements_test.txt b/requirements_test.txt index b89c87380..dc1e96479 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,2 +1,2 @@ -r requirements_dev.txt -pytest-homeassistant-custom-component==0.3.0 \ No newline at end of file +pytest-homeassistant-custom-component==0.4.2 \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py index 2be01f545..cc00dbcd0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -30,4 +30,9 @@ def skip_notifications_fixture(): with patch("homeassistant.components.persistent_notification.async_create"), patch( "homeassistant.components.persistent_notification.async_dismiss" ): - yield \ No newline at end of file + yield + + +@pytest.fixture(autouse=True) +def auto_enable_custom_integrations(enable_custom_integrations): + yield diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index 968ebc6ea..54b2906d4 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -1,32 +1,46 @@ -"""Test the Somfy TaHoma config flow.""" -from unittest.mock import patch +from unittest.mock import Mock, patch from aiohttp import ClientError -from homeassistant import config_entries, data_entry_flow -from pyhoma.exceptions import BadCredentialsException, TooManyRequestsException +from homeassistant import config_entries, data_entry_flow, setup +from pyhoma.exceptions import ( + BadCredentialsException, + MaintenanceException, + TooManyRequestsException, +) import pytest from pytest_homeassistant_custom_component.common import MockConfigEntry -from custom_components.tahoma.const import DOMAIN +from custom_components.tahoma import config_flow TEST_EMAIL = "test@testdomain.com" +TEST_EMAIL2 = "test@testdomain.nl" TEST_PASSWORD = "test-password" -DEFAULT_HUB = "Somfy TaHoma" +TEST_PASSWORD2 = "test-password2" +TEST_HUB = "Somfy TaHoma" +TEST_HUB2 = "Hi Kumo" +TEST_GATEWAY_ID = "1234-5678-9123" +TEST_GATEWAY_ID2 = "4321-5678-9123" + +MOCK_GATEWAY_RESPONSE = [Mock(id=TEST_GATEWAY_ID)] -async def test_form(hass, enable_custom_integrations): +async def test_form(hass): """Test we get the form.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + config_flow.DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["type"] == "form" assert result["errors"] == {} - with patch("pyhoma.client.TahomaClient.login", return_value=True): + with patch("pyhoma.client.TahomaClient.login", return_value=True), patch( + "pyhoma.client.TahomaClient.get_gateways", return_value=None + ), patch( + "custom_components.tahoma.async_setup_entry", return_value=True + ) as mock_setup_entry: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - {"username": TEST_EMAIL, "password": TEST_PASSWORD, "hub": DEFAULT_HUB}, + {"username": TEST_EMAIL, "password": TEST_PASSWORD, "hub": TEST_HUB}, ) assert result2["type"] == "create_entry" @@ -34,11 +48,13 @@ async def test_form(hass, enable_custom_integrations): assert result2["data"] == { "username": TEST_EMAIL, "password": TEST_PASSWORD, - "hub": DEFAULT_HUB, + "hub": TEST_HUB, } await hass.async_block_till_done() + assert len(mock_setup_entry.mock_calls) == 1 + @pytest.mark.parametrize( "side_effect, error", @@ -47,75 +63,68 @@ async def test_form(hass, enable_custom_integrations): (TooManyRequestsException, "too_many_requests"), (TimeoutError, "cannot_connect"), (ClientError, "cannot_connect"), + (MaintenanceException, "server_in_maintenance"), (Exception, "unknown"), ], ) -async def test_form_invalid(hass, side_effect, error, enable_custom_integrations): +async def test_form_invalid(hass, side_effect, error): """Test we handle invalid auth.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + config_flow.DOMAIN, context={"source": config_entries.SOURCE_USER} ) with patch("pyhoma.client.TahomaClient.login", side_effect=side_effect): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - {"username": TEST_EMAIL, "password": TEST_PASSWORD, "hub": DEFAULT_HUB}, + {"username": TEST_EMAIL, "password": TEST_PASSWORD, "hub": TEST_HUB}, ) - assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["type"] == "form" assert result2["errors"] == {"base": error} -async def test_abort_on_duplicate_entry(hass, enable_custom_integrations): +async def test_abort_on_duplicate_entry(hass): """Test config flow aborts Config Flow on duplicate entries.""" MockConfigEntry( - domain=DOMAIN, - unique_id=TEST_EMAIL, - data={"username": TEST_EMAIL, "password": TEST_PASSWORD, "hub": DEFAULT_HUB}, + domain=config_flow.DOMAIN, + unique_id=TEST_GATEWAY_ID, + data={"username": TEST_EMAIL, "password": TEST_PASSWORD, "hub": TEST_HUB}, ).add_to_hass(hass) result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + config_flow.DOMAIN, context={"source": config_entries.SOURCE_USER} ) with patch("pyhoma.client.TahomaClient.login", return_value=True), patch( - "custom_components.tahoma.async_setup", return_value=True - ) as mock_setup, patch( - "custom_components.tahoma.async_setup_entry", return_value=True - ) as mock_setup_entry: + "pyhoma.client.TahomaClient.get_gateways", return_value=MOCK_GATEWAY_RESPONSE + ), patch("custom_components.tahoma.async_setup_entry", return_value=True): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - {"username": TEST_EMAIL, "password": TEST_PASSWORD, "hub": DEFAULT_HUB}, + {"username": TEST_EMAIL, "password": TEST_PASSWORD}, ) assert result2["type"] == "abort" assert result2["reason"] == "already_configured" -async def test_allow_multiple_unique_entries(hass, enable_custom_integrations): +async def test_allow_multiple_unique_entries(hass): """Test config flow allows Config Flow unique entries.""" MockConfigEntry( - domain=DOMAIN, + domain=config_flow.DOMAIN, unique_id="test2@testdomain.com", - data={ - "username": "test2@testdomain.com", - "password": TEST_PASSWORD, - "hub": DEFAULT_HUB, - }, + data={"username": "test2@testdomain.com", "password": TEST_PASSWORD}, ).add_to_hass(hass) result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + config_flow.DOMAIN, context={"source": config_entries.SOURCE_USER} ) with patch("pyhoma.client.TahomaClient.login", return_value=True), patch( - "custom_components.tahoma.async_setup", return_value=True - ) as mock_setup, patch( - "custom_components.tahoma.async_setup_entry", return_value=True - ) as mock_setup_entry: + "pyhoma.client.TahomaClient.get_gateways", return_value=MOCK_GATEWAY_RESPONSE + ), patch("custom_components.tahoma.async_setup_entry", return_value=True): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - {"username": TEST_EMAIL, "password": TEST_PASSWORD, "hub": DEFAULT_HUB}, + {"username": TEST_EMAIL, "password": TEST_PASSWORD, "hub": TEST_HUB}, ) assert result2["type"] == "create_entry" @@ -123,24 +132,69 @@ async def test_allow_multiple_unique_entries(hass, enable_custom_integrations): assert result2["data"] == { "username": TEST_EMAIL, "password": TEST_PASSWORD, - "hub": DEFAULT_HUB, + "hub": TEST_HUB, } -async def test_import(hass, enable_custom_integrations): +async def test_reauth_success(hass): + """Test reauthentication flow.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + + with patch("pyhoma.client.TahomaClient.login", side_effect=BadCredentialsException): + mock_entry = MockConfigEntry( + domain=config_flow.DOMAIN, + unique_id=TEST_GATEWAY_ID, + data={"username": TEST_EMAIL, "password": TEST_PASSWORD, "hub": TEST_HUB2}, + ) + mock_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "unique_id": mock_entry.unique_id, + "entry_id": mock_entry.entry_id, + }, + data=mock_entry.data, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + + with patch("pyhoma.client.TahomaClient.login", return_value=True), patch( + "pyhoma.client.TahomaClient.get_gateways", return_value=MOCK_GATEWAY_RESPONSE + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + "username": TEST_EMAIL, + "password": TEST_PASSWORD2, + "hub": TEST_HUB2, + }, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "reauth_successful" + assert mock_entry.data["username"] == TEST_EMAIL + assert mock_entry.data["password"] == TEST_PASSWORD2 + + +async def test_import(hass): """Test config flow using configuration.yaml.""" with patch("pyhoma.client.TahomaClient.login", return_value=True), patch( + "pyhoma.client.TahomaClient.get_gateways", return_value=MOCK_GATEWAY_RESPONSE + ), patch( "custom_components.tahoma.async_setup", return_value=True ) as mock_setup, patch( "custom_components.tahoma.async_setup_entry", return_value=True ) as mock_setup_entry: result = await hass.config_entries.flow.async_init( - DOMAIN, + config_flow.DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data={ "username": TEST_EMAIL, "password": TEST_PASSWORD, - "hub": DEFAULT_HUB, + "hub": TEST_HUB, }, ) assert result["type"] == "create_entry" @@ -148,7 +202,7 @@ async def test_import(hass, enable_custom_integrations): assert result["data"] == { "username": TEST_EMAIL, "password": TEST_PASSWORD, - "hub": DEFAULT_HUB, + "hub": TEST_HUB, } await hass.async_block_till_done() @@ -171,12 +225,12 @@ async def test_import_failing(hass, side_effect, error, enable_custom_integratio """Test failing config flow using configuration.yaml.""" with patch("pyhoma.client.TahomaClient.login", side_effect=side_effect): await hass.config_entries.flow.async_init( - DOMAIN, + config_flow.DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data={ "username": TEST_EMAIL, "password": TEST_PASSWORD, - "hub": DEFAULT_HUB, + "hub": TEST_HUB, }, ) @@ -187,9 +241,9 @@ async def test_options_flow(hass, enable_custom_integrations): """Test options flow.""" entry = MockConfigEntry( - domain=DOMAIN, + domain=config_flow.DOMAIN, unique_id=TEST_EMAIL, - data={"username": TEST_EMAIL, "password": TEST_PASSWORD, "hub": DEFAULT_HUB}, + data={"username": TEST_EMAIL, "password": TEST_PASSWORD, "hub": TEST_HUB}, ) with patch("pyhoma.client.TahomaClient.login", return_value=True), patch( @@ -199,8 +253,8 @@ async def test_options_flow(hass, enable_custom_integrations): assert await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert len(hass.config_entries.async_entries(DOMAIN)) == 1 - assert entry.state == config_entries.ENTRY_STATE_LOADED + assert len(hass.config_entries.async_entries(config_flow.DOMAIN)) == 1 + assert entry.state == config_entries.ConfigEntryState.LOADED result = await hass.config_entries.options.async_init( entry.entry_id, context={"source": "test"}, data=None