Skip to content

Commit

Permalink
Change Honeywell somecomfort API to AIOSomecomfort API (#86102)
Browse files Browse the repository at this point in the history
* Move to AIOSomecomfort

* Remove unused constant

* Improve test coverage to 100

* Update homeassistant/components/honeywell/__init__.py

remove "todo" from code

Co-authored-by: Erik Montnemery <erik@montnemery.com>

* Missing cannot_connect translation

* add asyncio errors
update devices per entity
rework retry login

Co-authored-by: Erik Montnemery <erik@montnemery.com>
  • Loading branch information
mkmer and emontnemery authored Jan 18, 2023
1 parent f0ba7a3 commit 5e6ba59
Show file tree
Hide file tree
Showing 13 changed files with 183 additions and 199 deletions.
4 changes: 2 additions & 2 deletions CODEOWNERS
Validating CODEOWNERS rules …
Original file line number Diff line number Diff line change
Expand Up @@ -509,8 +509,8 @@ build.json @home-assistant/supervisor
/tests/components/homematic/ @pvizeli @danielperna84
/homeassistant/components/homewizard/ @DCSBL
/tests/components/homewizard/ @DCSBL
/homeassistant/components/honeywell/ @rdfurman
/tests/components/honeywell/ @rdfurman
/homeassistant/components/honeywell/ @rdfurman @mkmer
/tests/components/honeywell/ @rdfurman @mkmer
/homeassistant/components/http/ @home-assistant/core
/tests/components/http/ @home-assistant/core
/homeassistant/components/huawei_lte/ @scop @fphammerle
Expand Down
123 changes: 30 additions & 93 deletions homeassistant/components/honeywell/__init__.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
"""Support for Honeywell (US) Total Connect Comfort climate systems."""
import asyncio
from datetime import timedelta

import somecomfort
import AIOSomecomfort

from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.util import Throttle
from homeassistant.helpers.aiohttp_client import async_get_clientsession

from .const import (
_LOGGER,
Expand All @@ -20,7 +19,6 @@
)

UPDATE_LOOP_SLEEP_TIME = 5
MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=300)
PLATFORMS = [Platform.CLIMATE, Platform.SENSOR]

MIGRATE_OPTIONS_KEYS = {CONF_COOL_AWAY_TEMPERATURE, CONF_HEAT_AWAY_TEMPERATURE}
Expand Down Expand Up @@ -51,18 +49,33 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
username = config_entry.data[CONF_USERNAME]
password = config_entry.data[CONF_PASSWORD]

client = await hass.async_add_executor_job(
get_somecomfort_client, username, password
client = AIOSomecomfort.AIOSomeComfort(
username, password, session=async_get_clientsession(hass)
)
try:
await client.login()
await client.discover()

if client is None:
return False
except AIOSomecomfort.AuthError as ex:
raise ConfigEntryNotReady(
"Failed to initialize the Honeywell client: "
"Check your configuration (username, password), "
) from ex

except (
AIOSomecomfort.ConnectionError,
AIOSomecomfort.ConnectionTimeout,
asyncio.TimeoutError,
) as ex:
raise ConfigEntryNotReady(
"Failed to initialize the Honeywell client: "
"Connection error: maybe you have exceeded the API rate limit?"
) from ex

loc_id = config_entry.data.get(CONF_LOC_ID)
dev_id = config_entry.data.get(CONF_DEV_ID)

devices = {}

for location in client.locations_by_id.values():
if not loc_id or location.locationid == loc_id:
for device in location.devices_by_id.values():
Expand All @@ -74,7 +87,6 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
return False

data = HoneywellData(hass, config_entry, client, username, password, devices)
await data.async_update()
hass.data.setdefault(DOMAIN, {})
hass.data[DOMAIN][config_entry.entry_id] = data
await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
Expand All @@ -99,32 +111,17 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
return unload_ok


def get_somecomfort_client(username: str, password: str) -> somecomfort.SomeComfort:
"""Initialize the somecomfort client."""
try:
return somecomfort.SomeComfort(username, password)
except somecomfort.AuthError:
_LOGGER.error("Failed to login to honeywell account %s", username)
return None
except somecomfort.SomeComfortError as ex:
raise ConfigEntryNotReady(
"Failed to initialize the Honeywell client: "
"Check your configuration (username, password), "
"or maybe you have exceeded the API rate limit?"
) from ex


class HoneywellData:
"""Get the latest data and update."""

def __init__(
self,
hass: HomeAssistant,
config_entry: ConfigEntry,
client: somecomfort.SomeComfort,
client: AIOSomecomfort.AIOSomeComfort,
username: str,
password: str,
devices: dict[str, somecomfort.Device],
devices: dict[str, AIOSomecomfort.device.Device],
) -> None:
"""Initialize the data object."""
self._hass = hass
Expand All @@ -134,73 +131,13 @@ def __init__(
self._password = password
self.devices = devices

async def _retry(self) -> bool:
"""Recreate a new somecomfort client.
When we got an error, the best way to be sure that the next query
will succeed, is to recreate a new somecomfort client.
"""
self._client = await self._hass.async_add_executor_job(
get_somecomfort_client, self._username, self._password
)

if self._client is None:
return False
async def retry_login(self) -> bool:
"""Fire of a login retry."""

refreshed_devices = [
device
for location in self._client.locations_by_id.values()
for device in location.devices_by_id.values()
]

if len(refreshed_devices) == 0:
_LOGGER.error("Failed to find any devices after retry")
try:
await self._client.login()
except AIOSomecomfort.SomeComfortError:
await asyncio.sleep(UPDATE_LOOP_SLEEP_TIME)
return False

for updated_device in refreshed_devices:
if updated_device.deviceid in self.devices:
self.devices[updated_device.deviceid] = updated_device
else:
_LOGGER.info(
"New device with ID %s detected, reload the honeywell integration"
" if you want to access it in Home Assistant"
)

await self._hass.config_entries.async_reload(self._config.entry_id)
return True

async def _refresh_devices(self):
"""Refresh each enabled device."""
for device in self.devices.values():
await self._hass.async_add_executor_job(device.refresh)
await asyncio.sleep(UPDATE_LOOP_SLEEP_TIME)

@Throttle(MIN_TIME_BETWEEN_UPDATES)
async def async_update(self) -> None:
"""Update the state."""
retries = 3
while retries > 0:
try:
await self._refresh_devices()
break
except (
somecomfort.client.APIRateLimited,
somecomfort.client.ConnectionError,
somecomfort.client.ConnectionTimeout,
OSError,
) as exp:
retries -= 1
if retries == 0:
_LOGGER.error(
"Ran out of retry attempts (3 attempts allocated). Error: %s",
exp,
)
raise exp

result = await self._retry()

if not result:
_LOGGER.error("Retry result was empty. Error: %s", exp)
raise exp

_LOGGER.info("SomeComfort update failed, retrying. Error: %s", exp)
108 changes: 65 additions & 43 deletions homeassistant/components/honeywell/climate.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import datetime
from typing import Any

import somecomfort
import AIOSomecomfort

from homeassistant.components.climate import (
ATTR_TARGET_TEMP_HIGH,
Expand Down Expand Up @@ -70,7 +70,7 @@
"follow schedule": FAN_AUTO,
}

PARALLEL_UPDATES = 1
SCAN_INTERVAL = datetime.timedelta(seconds=10)


async def async_setup_entry(
Expand Down Expand Up @@ -230,7 +230,7 @@ def _is_permanent_hold(self) -> bool:
cool_status = self._device.raw_ui_data.get("StatusCool", 0)
return heat_status == 2 or cool_status == 2

def _set_temperature(self, **kwargs) -> None:
async def _set_temperature(self, **kwargs) -> None:
"""Set new target temperature."""
if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None:
return
Expand All @@ -246,35 +246,43 @@ def _set_temperature(self, **kwargs) -> None:
# Get next period time
hour, minute = divmod(next_period * 15, 60)
# Set hold time
setattr(self._device, f"hold_{mode}", datetime.time(hour, minute))
if mode == HVACMode.COOL:
await self._device.set_hold_cool(datetime.time(hour, minute))
elif mode == HVACMode.HEAT:
await self._device.set_hold_heat(datetime.time(hour, minute))

# Set temperature
setattr(self._device, f"setpoint_{mode}", temperature)
except somecomfort.SomeComfortError:
if mode == HVACMode.COOL:
await self._device.set_setpoint_cool(temperature)
elif mode == HVACMode.HEAT:
await self._device.set_setpoint_heat(temperature)

except AIOSomecomfort.SomeComfortError:
_LOGGER.error("Temperature %.1f out of range", temperature)

def set_temperature(self, **kwargs: Any) -> None:
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperature."""
if {HVACMode.COOL, HVACMode.HEAT} & set(self._hvac_mode_map):
self._set_temperature(**kwargs)
await self._set_temperature(**kwargs)

try:
if HVACMode.HEAT_COOL in self._hvac_mode_map:
if temperature := kwargs.get(ATTR_TARGET_TEMP_HIGH):
self._device.setpoint_cool = temperature
await self._device.set_setpoint_cool(temperature)
if temperature := kwargs.get(ATTR_TARGET_TEMP_LOW):
self._device.setpoint_heat = temperature
except somecomfort.SomeComfortError as err:
await self._device.set_setpoint_heat(temperature)
except AIOSomecomfort.SomeComfortError as err:
_LOGGER.error("Invalid temperature %s: %s", temperature, err)

def set_fan_mode(self, fan_mode: str) -> None:
async def async_set_fan_mode(self, fan_mode: str) -> None:
"""Set new target fan mode."""
self._device.fan_mode = self._fan_mode_map[fan_mode]
await self._device.set_fan_mode(self._fan_mode_map[fan_mode])

def set_hvac_mode(self, hvac_mode: HVACMode) -> None:
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set new target hvac mode."""
self._device.system_mode = self._hvac_mode_map[hvac_mode]
await self._device.set_system_mode(self._hvac_mode_map[hvac_mode])

def _turn_away_mode_on(self) -> None:
async def _turn_away_mode_on(self) -> None:
"""Turn away on.
Somecomfort does have a proprietary away mode, but it doesn't really
Expand All @@ -285,73 +293,87 @@ def _turn_away_mode_on(self) -> None:
try:
# Get current mode
mode = self._device.system_mode
except somecomfort.SomeComfortError:
except AIOSomecomfort.SomeComfortError:
_LOGGER.error("Can not get system mode")
return
try:

# Set permanent hold
setattr(self._device, f"hold_{mode}", True)
# Set temperature
setattr(
self._device,
f"setpoint_{mode}",
getattr(self, f"_{mode}_away_temp"),
)
except somecomfort.SomeComfortError:
# and Set temperature
away_temp = getattr(self, f"_{mode}_away_temp")
if mode == HVACMode.COOL:
self._device.set_hold_cool(True)
self._device.set_setpoint_cool(away_temp)
elif mode == HVACMode.HEAT:
self._device.set_hold_heat(True)
self._device.set_setpoint_heat(away_temp)

except AIOSomecomfort.SomeComfortError:
_LOGGER.error(
"Temperature %.1f out of range", getattr(self, f"_{mode}_away_temp")
)

def _turn_hold_mode_on(self) -> None:
async def _turn_hold_mode_on(self) -> None:
"""Turn permanent hold on."""
try:
# Get current mode
mode = self._device.system_mode
except somecomfort.SomeComfortError:
except AIOSomecomfort.SomeComfortError:
_LOGGER.error("Can not get system mode")
return
# Check that we got a valid mode back
if mode in HW_MODE_TO_HVAC_MODE:
try:
# Set permanent hold
setattr(self._device, f"hold_{mode}", True)
except somecomfort.SomeComfortError:
if mode == HVACMode.COOL:
await self._device.set_hold_cool(True)
elif mode == HVACMode.HEAT:
await self._device.set_hold_heat(True)

except AIOSomecomfort.SomeComfortError:
_LOGGER.error("Couldn't set permanent hold")
else:
_LOGGER.error("Invalid system mode returned: %s", mode)

def _turn_away_mode_off(self) -> None:
async def _turn_away_mode_off(self) -> None:
"""Turn away/hold off."""
self._away = False
try:
# Disabling all hold modes
self._device.hold_cool = False
self._device.hold_heat = False
except somecomfort.SomeComfortError:
await self._device.set_hold_cool(False)
await self._device.set_hold_heat(False)
except AIOSomecomfort.SomeComfortError:
_LOGGER.error("Can not stop hold mode")

def set_preset_mode(self, preset_mode: str) -> None:
async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set new preset mode."""
if preset_mode == PRESET_AWAY:
self._turn_away_mode_on()
await self._turn_away_mode_on()
elif preset_mode == PRESET_HOLD:
self._away = False
self._turn_hold_mode_on()
await self._turn_hold_mode_on()
else:
self._turn_away_mode_off()
await self._turn_away_mode_off()

def turn_aux_heat_on(self) -> None:
async def async_turn_aux_heat_on(self) -> None:
"""Turn auxiliary heater on."""
self._device.system_mode = "emheat"
await self._device.system_mode("emheat")

def turn_aux_heat_off(self) -> None:
async def async_turn_aux_heat_off(self) -> None:
"""Turn auxiliary heater off."""
if HVACMode.HEAT in self.hvac_modes:
self.set_hvac_mode(HVACMode.HEAT)
await self.async_set_hvac_mode(HVACMode.HEAT)
else:
self.set_hvac_mode(HVACMode.OFF)
await self.async_set_hvac_mode(HVACMode.OFF)

async def async_update(self) -> None:
"""Get the latest state from the service."""
await self._data.async_update()
try:
await self._device.refresh()
except (
AIOSomecomfort.device.APIRateLimited,
AIOSomecomfort.device.ConnectionError,
AIOSomecomfort.device.ConnectionTimeout,
OSError,
):
await self._data.retry_login()
Loading

0 comments on commit 5e6ba59

Please sign in to comment.