Module pyhtcc.pyhtcc

Holds implementation guts for PyHTCC

Expand source code
"""
Holds implementation guts for PyHTCC
"""
from __future__ import annotations

import datetime
import enum
import functools
import os
import re
import time
import typing

import requests  # depends

# logging setup
from csmlog import getLogger, setup  # depends
from deprecated import deprecated  # depends

setup("pyhtcc")
logger = getLogger(__file__)


class AuthenticationError(ValueError):
    """denoted if we are completely unable to authenticate (even after exponential backoff)"""

    pass


class LoginCredentialsInvalidError(ValueError):
    """denoted if it appears as though invalid login credentials were given"""

    pass


class UnauthorizedError(ValueError):
    """denoted if we are logged in, but received something akin to a 401 error"""

    pass


class LogoutFailureError(ValueError):
    """denoted if we are unable to logout"""

    pass


class NoSessionError(ValueError):
    """denotes if we have no logged in session"""

    pass


class UnexpectedError(EnvironmentError):
    """raised if a non json response denotes an unexpected error"""

    pass


class LoginUnexpectedError(UnexpectedError):
    """raised if we logged in, but the site says that there was an unexpected error via redirect"""

    pass


class TooManyAttemptsError(EnvironmentError):
    """raised if attempting to authenticate led to us being told we've tried too many times"""

    pass


class RedirectDidNotHappenError(EnvironmentError):
    """raised if we logged in, but the expected redirect didn't happen"""

    pass


class ZoneNotFoundError(EnvironmentError):
    """raised if the zone could not be found on refresh"""

    pass


class NoZonesFoundError(EnvironmentError):
    """Raised if there appear to be no zones in our current location"""

    pass


class SystemMode(enum.IntEnum):
    """
    Enum for which mode the system is currently using
    """

    EMHeat = 0
    Heat = 1
    Off = 2
    Cool = 3
    AutoHeat = 4
    AutoCool = 5
    SouthernAway = 6
    Unknown = 7


class FanMode(enum.IntEnum):
    """
    Enum for which mode the fan is currently using
    """

    Auto = 0
    On = 1
    Circulate = 2
    FollowSchedule = 3
    Unknown = 4


class Zone:
    """
    A Zone often equates to a given thermostat. The Zone object can be used to control the thermostat
        for the given zone.
    """

    def __init__(
        self,
        device_id_or_zone_info: typing.Union[int, str],
        pyhtcc: typing.TypeVar("PyHTCC"),
    ):
        """
        Initializer for a Zone object.
        Takes in a device_id or zone info dict object as the first param.
        Also takes in an authenticated instance of an PyHTCC object
        """
        if isinstance(device_id_or_zone_info, int):
            self.device_id = device_id_or_zone_info
            self.zone_info = {}
        elif isinstance(device_id_or_zone_info, dict):
            self.device_id = device_id_or_zone_info["DeviceID"]
            self.zone_info = device_id_or_zone_info

        self.pyhtcc = pyhtcc

        if not self.zone_info:
            # will create/populate self.zone_info
            self.refresh_zone_info()

    def refresh_zone_info(self) -> None:
        """refreshes the zone_info attribute"""
        all_zones_info = self.pyhtcc.get_zones_info()
        for z in all_zones_info:
            if z["DeviceID"] == self.device_id:
                logger.debug("Refreshed zone info for {self.device_id}")
                self.zone_info = z
                return

        raise ZoneNotFoundError(f"Missing device: {self.device_id}")

    def get_name(self) -> str:
        """gets the name corresponding with this Zone"""
        return self.zone_info["Name"]

    def _get_with_unit(self, raw) -> str:
        """takes the raw and adds a degree sign and a unit"""
        disp_unit = self.zone_info["DispUnits"]
        return f"{raw}°{disp_unit}"

    def get_system_mode(self) -> SystemMode:
        """
        refreshes the cached zone information then returns the current system mode
        """
        self.refresh_zone_info()
        return SystemMode(
            self.zone_info["latestData"]["uiData"]["SystemSwitchPosition"]
        )

    def is_equipment_output_on(self) -> bool:
        """
        Refreshes the cached zone information then Returns true if the EquipmentOutputStatus
        is non 0. This typically meansthe system is heating/cooling.
        """
        self.refresh_zone_info()
        return bool(self.zone_info["latestData"]["uiData"]["EquipmentOutputStatus"])

    def is_calling_for_heat(self) -> int:
        """
        Refreshes the cached zone information and checks if the system mode is heating
        """
        return (
            self.get_system_mode()
            in (SystemMode.Heat, SystemMode.AutoHeat, SystemMode.EMHeat)
            and self.is_equipment_output_on()
        )

    def is_calling_for_cool(self) -> int:
        """
        Refreshes the cached zone information and checks if the system mode is cooling
        """
        return (
            self.get_system_mode() in (SystemMode.Cool, SystemMode.AutoCool)
            and self.is_equipment_output_on()
        )

    def get_current_temperature_raw(self) -> int:
        """gets the current temperature via refreshing the cached zone information"""
        self.refresh_zone_info()
        if self.zone_info["DispTempAvailable"]:
            return int(self.zone_info["DispTemp"])

        raise KeyError("Temperature is unavailable")

    def get_current_temperature(self) -> str:
        """calls get_current_temperature_raw() then adds on a degree sign and the display unit"""
        raw = self.get_current_temperature_raw()
        return self._get_with_unit(raw)

    def get_fan_mode(self) -> FanMode:
        """
        refreshes the cached zone information then returns the current FanMode
        """
        self.refresh_zone_info()
        return FanMode(self.zone_info["latestData"]["fanData"]["fanMode"])

    def is_fan_running(self) -> bool:
        """
        refreshes the cached zone information then returns True if the fan is running
        """
        self.refresh_zone_info()
        return bool(self.zone_info["latestData"]["fanData"]["fanIsRunning"])

    def get_heat_setpoint_raw(self) -> int:
        """refreshes the cached zone information then returns the heat setpoint"""
        self.refresh_zone_info()
        return int(self.zone_info["latestData"]["uiData"]["HeatSetpoint"])

    def get_cool_setpoint_raw(self) -> int:
        """refreshes the cached zone information then returns the cool setpoint"""
        self.refresh_zone_info()
        return int(self.zone_info["latestData"]["uiData"]["CoolSetpoint"])

    def get_heat_setpoint(self) -> str:
        """calls get_heat_setpoint_raw() then adds on a degree sign and the display unit"""
        raw = self.get_heat_setpoint_raw()
        return self._get_with_unit(raw)

    def get_cool_setpoint(self) -> str:
        """calls get_cool_setpoint_raw() then adds on a degree sign and the display unit"""
        raw = self.get_cool_setpoint_raw()
        return self._get_with_unit(raw)

    def get_outdoor_temperature_raw(self) -> int:
        """refreshes the cached zone information then returns the outdoor temperature raw value"""
        self.refresh_zone_info()
        return self.zone_info["OutdoorTemperature"]

    def get_outdoor_temperature(self) -> str:
        """calls get_outdoor_temperature_raw() then returns it with a degree sign and the display unit"""
        raw = self.get_outdoor_temperature_raw()
        return self._get_with_unit(raw)

    def get_indoor_temperature_raw(self) -> int:
        """refreshes the cached zone information then returns the indoor temperature raw value"""
        self.refresh_zone_info()
        return self.zone_info["latestData"]["uiData"]["DispTemperature"]

    def get_indoor_temperature(self) -> str:
        """calls get_indoor_temperature_raw() then returns it with a degree sign and the Display unit"""
        raw = self.get_indoor_temperature_raw()
        return self._get_with_unit(raw)

    def get_indoor_humidity_raw(self) -> int:
        """refreshes the cached zone information then returns the indoor humidity raw value"""
        self.refresh_zone_info()
        return self.zone_info["latestData"]["uiData"]["IndoorHumidity"]

    def get_indoor_humidity(self) -> str:
        """calls get_indoor_humidity_raw() then returns it with a % display unit"""
        raw = self.get_indoor_humidity_raw()
        return str(raw) + str("%")

    def submit_control_changes(self, data: dict) -> None:
        """
        This is a low-level API call to PyHTCC.submit_raw_control_changes().
        More likely than not, most users need not use this call directly.
        """
        return self.pyhtcc.submit_raw_control_changes(self.device_id, data)

    @deprecated(
        version="0.1.11",
        reason="Use the correctly spelt: set_permanent_cool_setpoint() instead. set_permananent_cool_setpoint() will be removed in a future release.",
    )
    def set_permananent_cool_setpoint(self, temp: int) -> None:
        """deprecated... this is a misspelling of set_permanent_cool_setpoint()"""
        return self.set_permanent_cool_setpoint(temp)

    def set_permanent_cool_setpoint(self, temp: int) -> None:
        """
        Sets a new permanent cool setpoint.
        This will also attempt to turn the thermostat to 'Cool'
        """
        logger.info(f"setting cool on with a target temp of: {temp}")
        return self.submit_control_changes(
            {"CoolSetpoint": temp, "StatusHeat": 2, "StatusCool": 2, "SystemSwitch": 3}
        )

    @deprecated(
        version="0.1.11",
        reason="Use the correctly spelt: set_permanent_heat_setpoint() instead. set_permananent_heat_setpoint() will be removed in a future release.",
    )
    def set_permananent_heat_setpoint(self, temp: int) -> None:
        """deprecated... this is a misspelling of set_permanent_heat_setpoint()"""
        return self.set_permanent_heat_setpoint(temp)

    def set_permanent_heat_setpoint(self, temp: int) -> None:
        """
        Sets a new permanent heat setpoint.
        This will also attempt to turn the thermostat to 'Heat'
        """
        logger.info(f"setting heat on with a target temp of: {temp}")
        return self.submit_control_changes(
            {
                "HeatSetpoint": temp,
                "StatusHeat": 2,
                "StatusCool": 2,
                "SystemSwitch": 1,
            }
        )

    def _coerce_temp_end_to_setpoint(
        self, end: typing.Union[datetime.timedelta, datetime.time, None] = None
    ) -> typing.Union[None, int]:
        """
        Takes the given end and converts it into a 'NextPeriod' for use by submit_control_changes.
        This field is a 15 minute-based field.. so 0 = midnight, 1 = 12:15am, 2 = 12:30am, etc.

        a datetime.time translates directly while a datetime.timedelta will be a 'delta from now'.
        """
        ret = None
        if isinstance(end, datetime.time):
            ret = int((end.hour * 4) + round(end.minute / 15))
        elif isinstance(end, datetime.timedelta):
            if end.days > 0:
                raise ValueError("The timedelta must be less than a day")

            the_end = datetime.datetime.now() + end
            the_end_time = the_end.time()
            ret = self._coerce_temp_end_to_setpoint(the_end_time)
        elif isinstance(end, type(None)):
            pass
        else:
            raise ValueError(
                f"end must be either a datetime.time or datetime.timedelta, not a {type(end)}"
            )

        return ret

    def set_temp_heat_setpoint(
        self,
        temp: int,
        end: typing.Union[datetime.timedelta, datetime.time, None] = None,
    ) -> None:
        """
        Sets a new temporary heat setpoint.
        This will also attempt to turn the thermostat to 'Heat'

        If you provide an 'end' it should be either:
            - A datetime.timedelta for less than 24 hours from now
            OR
            - A datetime.time for a specific time of day (within the next 24 hours)
            OR
            - None corresponding with 'the thermostat will pick an end time'

        The end will automatically be rounded to the nearest 15 minute mark.
        """
        logger.info(f"setting temp heat on with a target temp of: {temp}")
        return self.submit_control_changes(
            {
                "HeatSetpoint": temp,
                "StatusHeat": 1,
                "StatusCool": 1,
                "SystemSwitch": 1,
                "HeatNextPeriod": self._coerce_temp_end_to_setpoint(end),
            }
        )

    def set_temp_cool_setpoint(
        self,
        temp: int,
        end: typing.Union[datetime.timedelta, datetime.time, None] = None,
    ) -> None:
        """
        Sets a new temporary cool setpoint.
        This will also attempt to turn the thermostat to 'Cool'

        If you provide an 'end' it should be either:
            - A datetime.timedelta for less than 24 hours from now
            OR
            - A datetime.time for a specific time of day (within the next 24 hours)
            OR
            - None corresponding with 'the thermostat will pick an end time'

        The end will automatically be rounded to the nearest 15 minute mark.
        """
        logger.info(f"setting temp heat on with a target temp of: {temp}")
        return self.submit_control_changes(
            {
                "CoolSetpoint": temp,
                "StatusHeat": 1,
                "StatusCool": 1,
                "SystemSwitch": 3,
                "CoolNextPeriod": self._coerce_temp_end_to_setpoint(end),
            }
        )

    def end_hold(self) -> None:
        """
        Requests that the zone end its current hold.
        Normally this tells the thermostat to resume its schedule.
        """
        logger.info("ending hold")
        return self.submit_control_changes(
            {
                "StatusHeat": 0,
                "StatusCool": 0,
            }
        )

    def turn_system_off(self) -> None:
        """turns this thermostat off"""
        logger.info("turning system off")
        return self.submit_control_changes(
            {
                "SystemSwitch": 2,
            }
        )

    def turn_fan_on(self) -> None:
        """turns the fan on"""
        logger.info("turning fan on")
        return self.submit_control_changes(
            {
                "FanMode": 1,
            }
        )

    def turn_fan_auto(self) -> None:
        """turns the fan to auto"""
        logger.info("turning fan to auto")
        return self.submit_control_changes(
            {
                "FanMode": 0,
            }
        )

    def turn_fan_circulate(self) -> None:
        """turns the fan to circulate"""
        logger.info("turning fan circulate")
        return self.submit_control_changes(
            {
                "FanMode": 2,
            }
        )


class PyHTCC:
    """
    Class that represents a Python object to control a Honeywell Total Connect Comfort thermostat system
    """

    def __init__(self, username: str, password: str):
        """
        Initializer for the PyHTCC object. Will save username and password, then call authenticate().
        """
        self.username = username
        self.password = password
        self._locationId = None
        self.session = None

        # self.session will be created in authenticate()
        self.authenticate()

    def authenticate(self) -> None:
        """
        Attempts to authenticate with mytotalconnectcomfort.com.
        Internally this will do exponential backoff if the portal rejects our sign on request.

        Note that the portal does have rate-limiting. This will attempt to retry with increasingly-long
            sleep intervals if rate-limiting is preventing sign-on.
        """
        for i in range(100):
            logger.debug(f"Starting authentication attempt #{i + 1}")
            try:
                return self._do_authenticate()
            except (
                TooManyAttemptsError,
                RedirectDidNotHappenError,
                LoginUnexpectedError,
            ):
                logger.exception("Unable to authenticate at this moment")
                num_seconds = 2**i
                logger.debug(f"Sleeping for {num_seconds} seconds")
                time.sleep(num_seconds)

        raise AuthenticationError("Unable to authenticate. Ran out of tries")

    def _ensure_session(func) -> None:
        """
        Will raise if we do not have a session to work with
        """

        @functools.wraps(func)
        def decorator(self, *args, **kwargs):
            if self.session is None:
                raise NoSessionError(
                    "Session is unavailable. Did you logout and forget to call authenticate() again?"
                )
            return func(self, *args, **kwargs)

        return decorator

    def _do_authenticate(self) -> None:
        """
        Attempts to perform the actual authentication.
        Will set: self.session and self._locationId

        Can raise various exceptions. Users are expected to use authenticate() instead of this method.
        """
        self.session = requests.session()

        # See https://github.com/psf/requests/issues/4564 for why we encode user/pass to bytes
        self.session.auth = (
            self.username.encode("utf-8"),
            self.password.encode("utf-8"),
        )

        logger.debug(f"Attempting authentication for {self.username}")

        result = self.session.post(
            "https://mytotalconnectcomfort.com/portal",
            {
                "UserName": self.username,
                "Password": self.password,
            },
        )

        if result.status_code != 200:
            raise AuthenticationError(
                f"Unable to authenticate as {self.username}. Status was: {result.status_code}"
            )

        if (
            "The email or password provided is incorrect" in result.text
            or "The email address is not in the correct format" in result.text
        ):
            raise LoginCredentialsInvalidError(
                f"Email ({self.username}) and/or password appear to have been rejected"
            )

        logger.debug(f"resulting url from authentication: {result.url}")

        if "TooManyAttempts" in result.url:
            raise TooManyAttemptsError(
                "url denoted that we have made too many attempts"
            )

        if "portal/" not in result.url:
            raise RedirectDidNotHappenError(
                f"{result.url} did not represent the needed redirect"
            )

        if "/Error" in result.url:
            raise LoginUnexpectedError(f"{result.url} denotes an error")

        self._set_location_id_from_result(result)

    @_ensure_session
    def logout(self) -> None:
        """
        Attempts to logout from mytotalconnectcomfort.com.

        Note that after calling this function, you must call authenticate() to login and get a new session.
        """
        logger.debug(f"Attempting to logout user: {self.username}")
        result = self.session.get(
            "https://mytotalconnectcomfort.com/portal/Account/LogOff"
        )
        if not result.ok:
            raise LogoutFailureError(
                f"Unable to logout user: {self.username}, status={result.status_code}"
            )

        logger.debug(f"Successfully logged out: {self.username}")
        self.session = None

    def _set_location_id_from_result(self, result):
        """
        Attempts to find the location id first from the url then if that fails, in the result's text content
        """
        try:
            self._locationId = int(result.url.split("portal/")[1].split("/")[0])
        except ValueError:
            logger.debug(
                "Unable to grab location id via url... checking content instead"
            )
            self._locationId = int(re.findall(r"locationId=(\d+)", result.text)[0])

        logger.debug(f"location id is {self._locationId}")

    @functools.lru_cache(maxsize=None)
    @_ensure_session
    def _get_name_for_device_id(self, device_id: int) -> str:
        """
        Will ask via the api for the name corresponding with the device id.
        Note that this actually greps the html for the name.
        Note that this will only perform an HTTP request if we don't already have this device_id's name cached
        """
        # grab the name from the portal
        result = self.session.get(
            f"https://mytotalconnectcomfort.com/portal/Device/Control/{device_id}?page=1"
        )
        result.raise_for_status()

        name = re.findall(r'id=\s?"ZoneName"\s?>(.*) Control<', result.text)[0]
        logger.debug(f"Called portal to say {device_id} -> {name}")
        return name

    @_ensure_session
    def _get_outdoor_weather_info_for_zone(self, device_id: int) -> dict:
        """
        Private API to find the outdoor information on one of the logged in pages
        """
        result = self.session.get(
            f"https://mytotalconnectcomfort.com/portal/Device/Control/{device_id}?page=1"
        )
        result.raise_for_status()

        text_data = result.text
        try:
            outdoor_temp = int(
                float(
                    text_data.split("Control.Model.Property.outdoorTemp,")[1]
                    .split(")", 1)[0]
                    .strip()
                )
            )
        except:
            logger.exception("Unable to find the outdoor temperature.")
            outdoor_temp = None

        try:
            outdoor_humidity = int(
                float(
                    text_data.split("Control.Model.Property.outdoorHumidity,")[1]
                    .split(")", 1)[0]
                    .strip()
                )
            )
        except:
            logger.exception("Unable to find the outdoor humidity.")
            outdoor_humidity = None

        return {
            "OutdoorTemperature": outdoor_temp,
            "OutdoorHumidity": outdoor_humidity,
        }

    def _post_zone_list_data(self, page_num: int) -> typing.Optional[dict]:
        """
        Private function to call the GetZoneListData api. On success returns the json data.

        Internally this function will catch UnexpectedError as that is expected when we read beyond the last page.

        See tests for sample output.
        """
        try:
            return self._request_json(
                "POST",
                f"https://mytotalconnectcomfort.com/portal/Device/GetZoneListData?locationId={self._locationId}&page={page_num}",
            )
        except UnexpectedError:
            return None

    def _get_check_data_session(self, device_id: int) -> dict:
        """
        Private function to call the CheckDataSession api. On success returns the json data.

        See tests for sample output.
        """
        return self._request_json(
            "GET",
            f"https://mytotalconnectcomfort.com/portal/Device/CheckDataSession/{device_id}",
        )

    @_ensure_session
    def _request_json(
        self, method: str, url: str, data: typing.Optional[dict] = None
    ) -> dict:
        """
        Private function to make a request and return the json data.

        Will attempt to sanity check the response and raise appropriate exceptions if something appears wrong.
        """
        result = self.session.request(
            method,
            url,
            json=data,
            headers={
                "accept": "application/json",
                "X-Requested-With": "XMLHttpRequest",
            },
        )

        try:
            result_json = result.json()
        except requests.exceptions.JSONDecodeError:
            result_json = None

        if result.status_code != 200 or result_json is None:
            logger.error(
                f"Got unexpected response from {url}: {result.status_code}. Data was:\n {result.text}"
            )

            if (
                "Unauthorized: Access is denied due to invalid credentials"
                in result.text
                or result.status_code == 401
            ):
                raise UnauthorizedError("Got unauthorized response from server")

            raise UnexpectedError("Expected json data in the response")

        return result_json

    def get_zones_info(self) -> list:
        """
        Returns a list of dicts corresponding with each one corresponding to a particular zone.
        """
        zones = []
        for page_num in range(1, 6):
            logger.debug(
                f"Attempting to get zones for location id, page: {self._locationId}, {page_num}"
            )
            data = self._post_zone_list_data(page_num)
            if page_num == 1 and not data:
                raise NoZonesFoundError("No zones were found from GetZoneListData")
            elif not data:
                # first empty page means we're done
                logger.debug(f"page {page_num} is empty")
                break

            # once we go to an empty page, we're done. Luckily it returns empty json instead of erroring
            if not data:
                logger.debug(f"page {page_num} is empty")
                break

            zones.extend(data)

        # add name (and additional info) to zone info
        for idx, zone in enumerate(zones):
            device_id = zone["DeviceID"]
            name = self._get_name_for_device_id(device_id)
            zone["Name"] = name

            device_id = zone["DeviceID"]
            more_data = self._get_check_data_session(device_id)

            zones[idx] = {
                **zone,
                **more_data,
                **self._get_outdoor_weather_info_for_zone(device_id),
            }

        return zones

    def get_all_zones(self) -> list:
        """
        Returns a list of Zone objects, corresponding with an object per zone on the account.
        """
        return [Zone(a, self) for a in self.get_zones_info()]

    def get_zone_by_name(self, name) -> Zone:
        """
        Will grab a Zone object for the given device name (not device id)
        """

        zone_info = self.get_zones_info()
        for a in zone_info:
            if a["Name"] == name:
                return Zone(a, self)

        raise NameError(f"Could not find a zone with the given name: {name}")

    def submit_raw_control_changes(self, device_id: int, other_data: dict) -> None:
        """
        Simulates making changes to current thermostat settings in the UI via
        the SubmitControlScreenChanges/ endpoint.
        """
        # None seems to mean no change to this control
        data = {
            "CoolNextPeriod": None,
            "CoolSetpoint": None,
            "DeviceID": device_id,
            "FanMode": None,
            "HeatNextPeriod": None,
            "HeatSetpoint": None,
            "StatusCool": None,
            "StatusHeat": None,
            "SystemSwitch": None,
        }

        # overwrite defaults with passed in data
        for k, v in other_data.items():
            if k not in data:
                raise KeyError(
                    f"Key: {k} was not one of the valid keys: {list(sorted(data.keys()))}"
                )
            data[k] = v

        logger.debug(f"Posting data to SubmitControlScreenChange: {data}")

        json_data = self._request_json(
            "POST",
            "https://mytotalconnectcomfort.com/portal/Device/SubmitControlScreenChanges",
            data=data,
        )

        if json_data["success"] != 1:
            raise ValueError(f"Success was not returned (success!=1): {json_data}")


if __name__ == "__main__":
    email = os.environ.get("PYHTCC_EMAIL")
    pw = os.environ.get("PYHTCC_PASS")
    if email and pw:
        h = PyHTCC(email, pw)
    else:
        print("Warning: no PYHTCC_EMAIL and PYHTCC_PASS were not set!")

Classes

class AuthenticationError (*args, **kwargs)

denoted if we are completely unable to authenticate (even after exponential backoff)

Expand source code
class AuthenticationError(ValueError):
    """denoted if we are completely unable to authenticate (even after exponential backoff)"""

    pass

Ancestors

  • builtins.ValueError
  • builtins.Exception
  • builtins.BaseException
class FanMode (*args, **kwds)

Enum for which mode the fan is currently using

Expand source code
class FanMode(enum.IntEnum):
    """
    Enum for which mode the fan is currently using
    """

    Auto = 0
    On = 1
    Circulate = 2
    FollowSchedule = 3
    Unknown = 4

Ancestors

  • enum.IntEnum
  • builtins.int
  • enum.ReprEnum
  • enum.Enum

Class variables

var Auto
var Circulate
var FollowSchedule
var On
var Unknown
class LoginCredentialsInvalidError (*args, **kwargs)

denoted if it appears as though invalid login credentials were given

Expand source code
class LoginCredentialsInvalidError(ValueError):
    """denoted if it appears as though invalid login credentials were given"""

    pass

Ancestors

  • builtins.ValueError
  • builtins.Exception
  • builtins.BaseException
class LoginUnexpectedError (*args, **kwargs)

raised if we logged in, but the site says that there was an unexpected error via redirect

Expand source code
class LoginUnexpectedError(UnexpectedError):
    """raised if we logged in, but the site says that there was an unexpected error via redirect"""

    pass

Ancestors

  • UnexpectedError
  • builtins.OSError
  • builtins.Exception
  • builtins.BaseException
class LogoutFailureError (*args, **kwargs)

denoted if we are unable to logout

Expand source code
class LogoutFailureError(ValueError):
    """denoted if we are unable to logout"""

    pass

Ancestors

  • builtins.ValueError
  • builtins.Exception
  • builtins.BaseException
class NoSessionError (*args, **kwargs)

denotes if we have no logged in session

Expand source code
class NoSessionError(ValueError):
    """denotes if we have no logged in session"""

    pass

Ancestors

  • builtins.ValueError
  • builtins.Exception
  • builtins.BaseException
class NoZonesFoundError (*args, **kwargs)

Raised if there appear to be no zones in our current location

Expand source code
class NoZonesFoundError(EnvironmentError):
    """Raised if there appear to be no zones in our current location"""

    pass

Ancestors

  • builtins.OSError
  • builtins.Exception
  • builtins.BaseException
class PyHTCC (username: str, password: str)

Class that represents a Python object to control a Honeywell Total Connect Comfort thermostat system

Initializer for the PyHTCC object. Will save username and password, then call authenticate().

Expand source code
class PyHTCC:
    """
    Class that represents a Python object to control a Honeywell Total Connect Comfort thermostat system
    """

    def __init__(self, username: str, password: str):
        """
        Initializer for the PyHTCC object. Will save username and password, then call authenticate().
        """
        self.username = username
        self.password = password
        self._locationId = None
        self.session = None

        # self.session will be created in authenticate()
        self.authenticate()

    def authenticate(self) -> None:
        """
        Attempts to authenticate with mytotalconnectcomfort.com.
        Internally this will do exponential backoff if the portal rejects our sign on request.

        Note that the portal does have rate-limiting. This will attempt to retry with increasingly-long
            sleep intervals if rate-limiting is preventing sign-on.
        """
        for i in range(100):
            logger.debug(f"Starting authentication attempt #{i + 1}")
            try:
                return self._do_authenticate()
            except (
                TooManyAttemptsError,
                RedirectDidNotHappenError,
                LoginUnexpectedError,
            ):
                logger.exception("Unable to authenticate at this moment")
                num_seconds = 2**i
                logger.debug(f"Sleeping for {num_seconds} seconds")
                time.sleep(num_seconds)

        raise AuthenticationError("Unable to authenticate. Ran out of tries")

    def _ensure_session(func) -> None:
        """
        Will raise if we do not have a session to work with
        """

        @functools.wraps(func)
        def decorator(self, *args, **kwargs):
            if self.session is None:
                raise NoSessionError(
                    "Session is unavailable. Did you logout and forget to call authenticate() again?"
                )
            return func(self, *args, **kwargs)

        return decorator

    def _do_authenticate(self) -> None:
        """
        Attempts to perform the actual authentication.
        Will set: self.session and self._locationId

        Can raise various exceptions. Users are expected to use authenticate() instead of this method.
        """
        self.session = requests.session()

        # See https://github.com/psf/requests/issues/4564 for why we encode user/pass to bytes
        self.session.auth = (
            self.username.encode("utf-8"),
            self.password.encode("utf-8"),
        )

        logger.debug(f"Attempting authentication for {self.username}")

        result = self.session.post(
            "https://mytotalconnectcomfort.com/portal",
            {
                "UserName": self.username,
                "Password": self.password,
            },
        )

        if result.status_code != 200:
            raise AuthenticationError(
                f"Unable to authenticate as {self.username}. Status was: {result.status_code}"
            )

        if (
            "The email or password provided is incorrect" in result.text
            or "The email address is not in the correct format" in result.text
        ):
            raise LoginCredentialsInvalidError(
                f"Email ({self.username}) and/or password appear to have been rejected"
            )

        logger.debug(f"resulting url from authentication: {result.url}")

        if "TooManyAttempts" in result.url:
            raise TooManyAttemptsError(
                "url denoted that we have made too many attempts"
            )

        if "portal/" not in result.url:
            raise RedirectDidNotHappenError(
                f"{result.url} did not represent the needed redirect"
            )

        if "/Error" in result.url:
            raise LoginUnexpectedError(f"{result.url} denotes an error")

        self._set_location_id_from_result(result)

    @_ensure_session
    def logout(self) -> None:
        """
        Attempts to logout from mytotalconnectcomfort.com.

        Note that after calling this function, you must call authenticate() to login and get a new session.
        """
        logger.debug(f"Attempting to logout user: {self.username}")
        result = self.session.get(
            "https://mytotalconnectcomfort.com/portal/Account/LogOff"
        )
        if not result.ok:
            raise LogoutFailureError(
                f"Unable to logout user: {self.username}, status={result.status_code}"
            )

        logger.debug(f"Successfully logged out: {self.username}")
        self.session = None

    def _set_location_id_from_result(self, result):
        """
        Attempts to find the location id first from the url then if that fails, in the result's text content
        """
        try:
            self._locationId = int(result.url.split("portal/")[1].split("/")[0])
        except ValueError:
            logger.debug(
                "Unable to grab location id via url... checking content instead"
            )
            self._locationId = int(re.findall(r"locationId=(\d+)", result.text)[0])

        logger.debug(f"location id is {self._locationId}")

    @functools.lru_cache(maxsize=None)
    @_ensure_session
    def _get_name_for_device_id(self, device_id: int) -> str:
        """
        Will ask via the api for the name corresponding with the device id.
        Note that this actually greps the html for the name.
        Note that this will only perform an HTTP request if we don't already have this device_id's name cached
        """
        # grab the name from the portal
        result = self.session.get(
            f"https://mytotalconnectcomfort.com/portal/Device/Control/{device_id}?page=1"
        )
        result.raise_for_status()

        name = re.findall(r'id=\s?"ZoneName"\s?>(.*) Control<', result.text)[0]
        logger.debug(f"Called portal to say {device_id} -> {name}")
        return name

    @_ensure_session
    def _get_outdoor_weather_info_for_zone(self, device_id: int) -> dict:
        """
        Private API to find the outdoor information on one of the logged in pages
        """
        result = self.session.get(
            f"https://mytotalconnectcomfort.com/portal/Device/Control/{device_id}?page=1"
        )
        result.raise_for_status()

        text_data = result.text
        try:
            outdoor_temp = int(
                float(
                    text_data.split("Control.Model.Property.outdoorTemp,")[1]
                    .split(")", 1)[0]
                    .strip()
                )
            )
        except:
            logger.exception("Unable to find the outdoor temperature.")
            outdoor_temp = None

        try:
            outdoor_humidity = int(
                float(
                    text_data.split("Control.Model.Property.outdoorHumidity,")[1]
                    .split(")", 1)[0]
                    .strip()
                )
            )
        except:
            logger.exception("Unable to find the outdoor humidity.")
            outdoor_humidity = None

        return {
            "OutdoorTemperature": outdoor_temp,
            "OutdoorHumidity": outdoor_humidity,
        }

    def _post_zone_list_data(self, page_num: int) -> typing.Optional[dict]:
        """
        Private function to call the GetZoneListData api. On success returns the json data.

        Internally this function will catch UnexpectedError as that is expected when we read beyond the last page.

        See tests for sample output.
        """
        try:
            return self._request_json(
                "POST",
                f"https://mytotalconnectcomfort.com/portal/Device/GetZoneListData?locationId={self._locationId}&page={page_num}",
            )
        except UnexpectedError:
            return None

    def _get_check_data_session(self, device_id: int) -> dict:
        """
        Private function to call the CheckDataSession api. On success returns the json data.

        See tests for sample output.
        """
        return self._request_json(
            "GET",
            f"https://mytotalconnectcomfort.com/portal/Device/CheckDataSession/{device_id}",
        )

    @_ensure_session
    def _request_json(
        self, method: str, url: str, data: typing.Optional[dict] = None
    ) -> dict:
        """
        Private function to make a request and return the json data.

        Will attempt to sanity check the response and raise appropriate exceptions if something appears wrong.
        """
        result = self.session.request(
            method,
            url,
            json=data,
            headers={
                "accept": "application/json",
                "X-Requested-With": "XMLHttpRequest",
            },
        )

        try:
            result_json = result.json()
        except requests.exceptions.JSONDecodeError:
            result_json = None

        if result.status_code != 200 or result_json is None:
            logger.error(
                f"Got unexpected response from {url}: {result.status_code}. Data was:\n {result.text}"
            )

            if (
                "Unauthorized: Access is denied due to invalid credentials"
                in result.text
                or result.status_code == 401
            ):
                raise UnauthorizedError("Got unauthorized response from server")

            raise UnexpectedError("Expected json data in the response")

        return result_json

    def get_zones_info(self) -> list:
        """
        Returns a list of dicts corresponding with each one corresponding to a particular zone.
        """
        zones = []
        for page_num in range(1, 6):
            logger.debug(
                f"Attempting to get zones for location id, page: {self._locationId}, {page_num}"
            )
            data = self._post_zone_list_data(page_num)
            if page_num == 1 and not data:
                raise NoZonesFoundError("No zones were found from GetZoneListData")
            elif not data:
                # first empty page means we're done
                logger.debug(f"page {page_num} is empty")
                break

            # once we go to an empty page, we're done. Luckily it returns empty json instead of erroring
            if not data:
                logger.debug(f"page {page_num} is empty")
                break

            zones.extend(data)

        # add name (and additional info) to zone info
        for idx, zone in enumerate(zones):
            device_id = zone["DeviceID"]
            name = self._get_name_for_device_id(device_id)
            zone["Name"] = name

            device_id = zone["DeviceID"]
            more_data = self._get_check_data_session(device_id)

            zones[idx] = {
                **zone,
                **more_data,
                **self._get_outdoor_weather_info_for_zone(device_id),
            }

        return zones

    def get_all_zones(self) -> list:
        """
        Returns a list of Zone objects, corresponding with an object per zone on the account.
        """
        return [Zone(a, self) for a in self.get_zones_info()]

    def get_zone_by_name(self, name) -> Zone:
        """
        Will grab a Zone object for the given device name (not device id)
        """

        zone_info = self.get_zones_info()
        for a in zone_info:
            if a["Name"] == name:
                return Zone(a, self)

        raise NameError(f"Could not find a zone with the given name: {name}")

    def submit_raw_control_changes(self, device_id: int, other_data: dict) -> None:
        """
        Simulates making changes to current thermostat settings in the UI via
        the SubmitControlScreenChanges/ endpoint.
        """
        # None seems to mean no change to this control
        data = {
            "CoolNextPeriod": None,
            "CoolSetpoint": None,
            "DeviceID": device_id,
            "FanMode": None,
            "HeatNextPeriod": None,
            "HeatSetpoint": None,
            "StatusCool": None,
            "StatusHeat": None,
            "SystemSwitch": None,
        }

        # overwrite defaults with passed in data
        for k, v in other_data.items():
            if k not in data:
                raise KeyError(
                    f"Key: {k} was not one of the valid keys: {list(sorted(data.keys()))}"
                )
            data[k] = v

        logger.debug(f"Posting data to SubmitControlScreenChange: {data}")

        json_data = self._request_json(
            "POST",
            "https://mytotalconnectcomfort.com/portal/Device/SubmitControlScreenChanges",
            data=data,
        )

        if json_data["success"] != 1:
            raise ValueError(f"Success was not returned (success!=1): {json_data}")

Methods

def authenticate(self) ‑> None

Attempts to authenticate with mytotalconnectcomfort.com. Internally this will do exponential backoff if the portal rejects our sign on request.

Note that the portal does have rate-limiting. This will attempt to retry with increasingly-long sleep intervals if rate-limiting is preventing sign-on.

Expand source code
def authenticate(self) -> None:
    """
    Attempts to authenticate with mytotalconnectcomfort.com.
    Internally this will do exponential backoff if the portal rejects our sign on request.

    Note that the portal does have rate-limiting. This will attempt to retry with increasingly-long
        sleep intervals if rate-limiting is preventing sign-on.
    """
    for i in range(100):
        logger.debug(f"Starting authentication attempt #{i + 1}")
        try:
            return self._do_authenticate()
        except (
            TooManyAttemptsError,
            RedirectDidNotHappenError,
            LoginUnexpectedError,
        ):
            logger.exception("Unable to authenticate at this moment")
            num_seconds = 2**i
            logger.debug(f"Sleeping for {num_seconds} seconds")
            time.sleep(num_seconds)

    raise AuthenticationError("Unable to authenticate. Ran out of tries")
def get_all_zones(self) ‑> list

Returns a list of Zone objects, corresponding with an object per zone on the account.

Expand source code
def get_all_zones(self) -> list:
    """
    Returns a list of Zone objects, corresponding with an object per zone on the account.
    """
    return [Zone(a, self) for a in self.get_zones_info()]
def get_zone_by_name(self, name) ‑> Zone

Will grab a Zone object for the given device name (not device id)

Expand source code
def get_zone_by_name(self, name) -> Zone:
    """
    Will grab a Zone object for the given device name (not device id)
    """

    zone_info = self.get_zones_info()
    for a in zone_info:
        if a["Name"] == name:
            return Zone(a, self)

    raise NameError(f"Could not find a zone with the given name: {name}")
def get_zones_info(self) ‑> list

Returns a list of dicts corresponding with each one corresponding to a particular zone.

Expand source code
def get_zones_info(self) -> list:
    """
    Returns a list of dicts corresponding with each one corresponding to a particular zone.
    """
    zones = []
    for page_num in range(1, 6):
        logger.debug(
            f"Attempting to get zones for location id, page: {self._locationId}, {page_num}"
        )
        data = self._post_zone_list_data(page_num)
        if page_num == 1 and not data:
            raise NoZonesFoundError("No zones were found from GetZoneListData")
        elif not data:
            # first empty page means we're done
            logger.debug(f"page {page_num} is empty")
            break

        # once we go to an empty page, we're done. Luckily it returns empty json instead of erroring
        if not data:
            logger.debug(f"page {page_num} is empty")
            break

        zones.extend(data)

    # add name (and additional info) to zone info
    for idx, zone in enumerate(zones):
        device_id = zone["DeviceID"]
        name = self._get_name_for_device_id(device_id)
        zone["Name"] = name

        device_id = zone["DeviceID"]
        more_data = self._get_check_data_session(device_id)

        zones[idx] = {
            **zone,
            **more_data,
            **self._get_outdoor_weather_info_for_zone(device_id),
        }

    return zones
def logout(self) ‑> None

Attempts to logout from mytotalconnectcomfort.com.

Note that after calling this function, you must call authenticate() to login and get a new session.

Expand source code
@_ensure_session
def logout(self) -> None:
    """
    Attempts to logout from mytotalconnectcomfort.com.

    Note that after calling this function, you must call authenticate() to login and get a new session.
    """
    logger.debug(f"Attempting to logout user: {self.username}")
    result = self.session.get(
        "https://mytotalconnectcomfort.com/portal/Account/LogOff"
    )
    if not result.ok:
        raise LogoutFailureError(
            f"Unable to logout user: {self.username}, status={result.status_code}"
        )

    logger.debug(f"Successfully logged out: {self.username}")
    self.session = None
def submit_raw_control_changes(self, device_id: int, other_data: dict) ‑> None

Simulates making changes to current thermostat settings in the UI via the SubmitControlScreenChanges/ endpoint.

Expand source code
def submit_raw_control_changes(self, device_id: int, other_data: dict) -> None:
    """
    Simulates making changes to current thermostat settings in the UI via
    the SubmitControlScreenChanges/ endpoint.
    """
    # None seems to mean no change to this control
    data = {
        "CoolNextPeriod": None,
        "CoolSetpoint": None,
        "DeviceID": device_id,
        "FanMode": None,
        "HeatNextPeriod": None,
        "HeatSetpoint": None,
        "StatusCool": None,
        "StatusHeat": None,
        "SystemSwitch": None,
    }

    # overwrite defaults with passed in data
    for k, v in other_data.items():
        if k not in data:
            raise KeyError(
                f"Key: {k} was not one of the valid keys: {list(sorted(data.keys()))}"
            )
        data[k] = v

    logger.debug(f"Posting data to SubmitControlScreenChange: {data}")

    json_data = self._request_json(
        "POST",
        "https://mytotalconnectcomfort.com/portal/Device/SubmitControlScreenChanges",
        data=data,
    )

    if json_data["success"] != 1:
        raise ValueError(f"Success was not returned (success!=1): {json_data}")
class RedirectDidNotHappenError (*args, **kwargs)

raised if we logged in, but the expected redirect didn't happen

Expand source code
class RedirectDidNotHappenError(EnvironmentError):
    """raised if we logged in, but the expected redirect didn't happen"""

    pass

Ancestors

  • builtins.OSError
  • builtins.Exception
  • builtins.BaseException
class SystemMode (*args, **kwds)

Enum for which mode the system is currently using

Expand source code
class SystemMode(enum.IntEnum):
    """
    Enum for which mode the system is currently using
    """

    EMHeat = 0
    Heat = 1
    Off = 2
    Cool = 3
    AutoHeat = 4
    AutoCool = 5
    SouthernAway = 6
    Unknown = 7

Ancestors

  • enum.IntEnum
  • builtins.int
  • enum.ReprEnum
  • enum.Enum

Class variables

var AutoCool
var AutoHeat
var Cool
var EMHeat
var Heat
var Off
var SouthernAway
var Unknown
class TooManyAttemptsError (*args, **kwargs)

raised if attempting to authenticate led to us being told we've tried too many times

Expand source code
class TooManyAttemptsError(EnvironmentError):
    """raised if attempting to authenticate led to us being told we've tried too many times"""

    pass

Ancestors

  • builtins.OSError
  • builtins.Exception
  • builtins.BaseException
class UnauthorizedError (*args, **kwargs)

denoted if we are logged in, but received something akin to a 401 error

Expand source code
class UnauthorizedError(ValueError):
    """denoted if we are logged in, but received something akin to a 401 error"""

    pass

Ancestors

  • builtins.ValueError
  • builtins.Exception
  • builtins.BaseException
class UnexpectedError (*args, **kwargs)

raised if a non json response denotes an unexpected error

Expand source code
class UnexpectedError(EnvironmentError):
    """raised if a non json response denotes an unexpected error"""

    pass

Ancestors

  • builtins.OSError
  • builtins.Exception
  • builtins.BaseException

Subclasses

class Zone (device_id_or_zone_info: typing.Union[int, str], pyhtcc: "typing.TypeVar('PyHTCC')")

A Zone often equates to a given thermostat. The Zone object can be used to control the thermostat for the given zone.

Initializer for a Zone object. Takes in a device_id or zone info dict object as the first param. Also takes in an authenticated instance of an PyHTCC object

Expand source code
class Zone:
    """
    A Zone often equates to a given thermostat. The Zone object can be used to control the thermostat
        for the given zone.
    """

    def __init__(
        self,
        device_id_or_zone_info: typing.Union[int, str],
        pyhtcc: typing.TypeVar("PyHTCC"),
    ):
        """
        Initializer for a Zone object.
        Takes in a device_id or zone info dict object as the first param.
        Also takes in an authenticated instance of an PyHTCC object
        """
        if isinstance(device_id_or_zone_info, int):
            self.device_id = device_id_or_zone_info
            self.zone_info = {}
        elif isinstance(device_id_or_zone_info, dict):
            self.device_id = device_id_or_zone_info["DeviceID"]
            self.zone_info = device_id_or_zone_info

        self.pyhtcc = pyhtcc

        if not self.zone_info:
            # will create/populate self.zone_info
            self.refresh_zone_info()

    def refresh_zone_info(self) -> None:
        """refreshes the zone_info attribute"""
        all_zones_info = self.pyhtcc.get_zones_info()
        for z in all_zones_info:
            if z["DeviceID"] == self.device_id:
                logger.debug("Refreshed zone info for {self.device_id}")
                self.zone_info = z
                return

        raise ZoneNotFoundError(f"Missing device: {self.device_id}")

    def get_name(self) -> str:
        """gets the name corresponding with this Zone"""
        return self.zone_info["Name"]

    def _get_with_unit(self, raw) -> str:
        """takes the raw and adds a degree sign and a unit"""
        disp_unit = self.zone_info["DispUnits"]
        return f"{raw}°{disp_unit}"

    def get_system_mode(self) -> SystemMode:
        """
        refreshes the cached zone information then returns the current system mode
        """
        self.refresh_zone_info()
        return SystemMode(
            self.zone_info["latestData"]["uiData"]["SystemSwitchPosition"]
        )

    def is_equipment_output_on(self) -> bool:
        """
        Refreshes the cached zone information then Returns true if the EquipmentOutputStatus
        is non 0. This typically meansthe system is heating/cooling.
        """
        self.refresh_zone_info()
        return bool(self.zone_info["latestData"]["uiData"]["EquipmentOutputStatus"])

    def is_calling_for_heat(self) -> int:
        """
        Refreshes the cached zone information and checks if the system mode is heating
        """
        return (
            self.get_system_mode()
            in (SystemMode.Heat, SystemMode.AutoHeat, SystemMode.EMHeat)
            and self.is_equipment_output_on()
        )

    def is_calling_for_cool(self) -> int:
        """
        Refreshes the cached zone information and checks if the system mode is cooling
        """
        return (
            self.get_system_mode() in (SystemMode.Cool, SystemMode.AutoCool)
            and self.is_equipment_output_on()
        )

    def get_current_temperature_raw(self) -> int:
        """gets the current temperature via refreshing the cached zone information"""
        self.refresh_zone_info()
        if self.zone_info["DispTempAvailable"]:
            return int(self.zone_info["DispTemp"])

        raise KeyError("Temperature is unavailable")

    def get_current_temperature(self) -> str:
        """calls get_current_temperature_raw() then adds on a degree sign and the display unit"""
        raw = self.get_current_temperature_raw()
        return self._get_with_unit(raw)

    def get_fan_mode(self) -> FanMode:
        """
        refreshes the cached zone information then returns the current FanMode
        """
        self.refresh_zone_info()
        return FanMode(self.zone_info["latestData"]["fanData"]["fanMode"])

    def is_fan_running(self) -> bool:
        """
        refreshes the cached zone information then returns True if the fan is running
        """
        self.refresh_zone_info()
        return bool(self.zone_info["latestData"]["fanData"]["fanIsRunning"])

    def get_heat_setpoint_raw(self) -> int:
        """refreshes the cached zone information then returns the heat setpoint"""
        self.refresh_zone_info()
        return int(self.zone_info["latestData"]["uiData"]["HeatSetpoint"])

    def get_cool_setpoint_raw(self) -> int:
        """refreshes the cached zone information then returns the cool setpoint"""
        self.refresh_zone_info()
        return int(self.zone_info["latestData"]["uiData"]["CoolSetpoint"])

    def get_heat_setpoint(self) -> str:
        """calls get_heat_setpoint_raw() then adds on a degree sign and the display unit"""
        raw = self.get_heat_setpoint_raw()
        return self._get_with_unit(raw)

    def get_cool_setpoint(self) -> str:
        """calls get_cool_setpoint_raw() then adds on a degree sign and the display unit"""
        raw = self.get_cool_setpoint_raw()
        return self._get_with_unit(raw)

    def get_outdoor_temperature_raw(self) -> int:
        """refreshes the cached zone information then returns the outdoor temperature raw value"""
        self.refresh_zone_info()
        return self.zone_info["OutdoorTemperature"]

    def get_outdoor_temperature(self) -> str:
        """calls get_outdoor_temperature_raw() then returns it with a degree sign and the display unit"""
        raw = self.get_outdoor_temperature_raw()
        return self._get_with_unit(raw)

    def get_indoor_temperature_raw(self) -> int:
        """refreshes the cached zone information then returns the indoor temperature raw value"""
        self.refresh_zone_info()
        return self.zone_info["latestData"]["uiData"]["DispTemperature"]

    def get_indoor_temperature(self) -> str:
        """calls get_indoor_temperature_raw() then returns it with a degree sign and the Display unit"""
        raw = self.get_indoor_temperature_raw()
        return self._get_with_unit(raw)

    def get_indoor_humidity_raw(self) -> int:
        """refreshes the cached zone information then returns the indoor humidity raw value"""
        self.refresh_zone_info()
        return self.zone_info["latestData"]["uiData"]["IndoorHumidity"]

    def get_indoor_humidity(self) -> str:
        """calls get_indoor_humidity_raw() then returns it with a % display unit"""
        raw = self.get_indoor_humidity_raw()
        return str(raw) + str("%")

    def submit_control_changes(self, data: dict) -> None:
        """
        This is a low-level API call to PyHTCC.submit_raw_control_changes().
        More likely than not, most users need not use this call directly.
        """
        return self.pyhtcc.submit_raw_control_changes(self.device_id, data)

    @deprecated(
        version="0.1.11",
        reason="Use the correctly spelt: set_permanent_cool_setpoint() instead. set_permananent_cool_setpoint() will be removed in a future release.",
    )
    def set_permananent_cool_setpoint(self, temp: int) -> None:
        """deprecated... this is a misspelling of set_permanent_cool_setpoint()"""
        return self.set_permanent_cool_setpoint(temp)

    def set_permanent_cool_setpoint(self, temp: int) -> None:
        """
        Sets a new permanent cool setpoint.
        This will also attempt to turn the thermostat to 'Cool'
        """
        logger.info(f"setting cool on with a target temp of: {temp}")
        return self.submit_control_changes(
            {"CoolSetpoint": temp, "StatusHeat": 2, "StatusCool": 2, "SystemSwitch": 3}
        )

    @deprecated(
        version="0.1.11",
        reason="Use the correctly spelt: set_permanent_heat_setpoint() instead. set_permananent_heat_setpoint() will be removed in a future release.",
    )
    def set_permananent_heat_setpoint(self, temp: int) -> None:
        """deprecated... this is a misspelling of set_permanent_heat_setpoint()"""
        return self.set_permanent_heat_setpoint(temp)

    def set_permanent_heat_setpoint(self, temp: int) -> None:
        """
        Sets a new permanent heat setpoint.
        This will also attempt to turn the thermostat to 'Heat'
        """
        logger.info(f"setting heat on with a target temp of: {temp}")
        return self.submit_control_changes(
            {
                "HeatSetpoint": temp,
                "StatusHeat": 2,
                "StatusCool": 2,
                "SystemSwitch": 1,
            }
        )

    def _coerce_temp_end_to_setpoint(
        self, end: typing.Union[datetime.timedelta, datetime.time, None] = None
    ) -> typing.Union[None, int]:
        """
        Takes the given end and converts it into a 'NextPeriod' for use by submit_control_changes.
        This field is a 15 minute-based field.. so 0 = midnight, 1 = 12:15am, 2 = 12:30am, etc.

        a datetime.time translates directly while a datetime.timedelta will be a 'delta from now'.
        """
        ret = None
        if isinstance(end, datetime.time):
            ret = int((end.hour * 4) + round(end.minute / 15))
        elif isinstance(end, datetime.timedelta):
            if end.days > 0:
                raise ValueError("The timedelta must be less than a day")

            the_end = datetime.datetime.now() + end
            the_end_time = the_end.time()
            ret = self._coerce_temp_end_to_setpoint(the_end_time)
        elif isinstance(end, type(None)):
            pass
        else:
            raise ValueError(
                f"end must be either a datetime.time or datetime.timedelta, not a {type(end)}"
            )

        return ret

    def set_temp_heat_setpoint(
        self,
        temp: int,
        end: typing.Union[datetime.timedelta, datetime.time, None] = None,
    ) -> None:
        """
        Sets a new temporary heat setpoint.
        This will also attempt to turn the thermostat to 'Heat'

        If you provide an 'end' it should be either:
            - A datetime.timedelta for less than 24 hours from now
            OR
            - A datetime.time for a specific time of day (within the next 24 hours)
            OR
            - None corresponding with 'the thermostat will pick an end time'

        The end will automatically be rounded to the nearest 15 minute mark.
        """
        logger.info(f"setting temp heat on with a target temp of: {temp}")
        return self.submit_control_changes(
            {
                "HeatSetpoint": temp,
                "StatusHeat": 1,
                "StatusCool": 1,
                "SystemSwitch": 1,
                "HeatNextPeriod": self._coerce_temp_end_to_setpoint(end),
            }
        )

    def set_temp_cool_setpoint(
        self,
        temp: int,
        end: typing.Union[datetime.timedelta, datetime.time, None] = None,
    ) -> None:
        """
        Sets a new temporary cool setpoint.
        This will also attempt to turn the thermostat to 'Cool'

        If you provide an 'end' it should be either:
            - A datetime.timedelta for less than 24 hours from now
            OR
            - A datetime.time for a specific time of day (within the next 24 hours)
            OR
            - None corresponding with 'the thermostat will pick an end time'

        The end will automatically be rounded to the nearest 15 minute mark.
        """
        logger.info(f"setting temp heat on with a target temp of: {temp}")
        return self.submit_control_changes(
            {
                "CoolSetpoint": temp,
                "StatusHeat": 1,
                "StatusCool": 1,
                "SystemSwitch": 3,
                "CoolNextPeriod": self._coerce_temp_end_to_setpoint(end),
            }
        )

    def end_hold(self) -> None:
        """
        Requests that the zone end its current hold.
        Normally this tells the thermostat to resume its schedule.
        """
        logger.info("ending hold")
        return self.submit_control_changes(
            {
                "StatusHeat": 0,
                "StatusCool": 0,
            }
        )

    def turn_system_off(self) -> None:
        """turns this thermostat off"""
        logger.info("turning system off")
        return self.submit_control_changes(
            {
                "SystemSwitch": 2,
            }
        )

    def turn_fan_on(self) -> None:
        """turns the fan on"""
        logger.info("turning fan on")
        return self.submit_control_changes(
            {
                "FanMode": 1,
            }
        )

    def turn_fan_auto(self) -> None:
        """turns the fan to auto"""
        logger.info("turning fan to auto")
        return self.submit_control_changes(
            {
                "FanMode": 0,
            }
        )

    def turn_fan_circulate(self) -> None:
        """turns the fan to circulate"""
        logger.info("turning fan circulate")
        return self.submit_control_changes(
            {
                "FanMode": 2,
            }
        )

Methods

def end_hold(self) ‑> None

Requests that the zone end its current hold. Normally this tells the thermostat to resume its schedule.

Expand source code
def end_hold(self) -> None:
    """
    Requests that the zone end its current hold.
    Normally this tells the thermostat to resume its schedule.
    """
    logger.info("ending hold")
    return self.submit_control_changes(
        {
            "StatusHeat": 0,
            "StatusCool": 0,
        }
    )
def get_cool_setpoint(self) ‑> str

calls get_cool_setpoint_raw() then adds on a degree sign and the display unit

Expand source code
def get_cool_setpoint(self) -> str:
    """calls get_cool_setpoint_raw() then adds on a degree sign and the display unit"""
    raw = self.get_cool_setpoint_raw()
    return self._get_with_unit(raw)
def get_cool_setpoint_raw(self) ‑> int

refreshes the cached zone information then returns the cool setpoint

Expand source code
def get_cool_setpoint_raw(self) -> int:
    """refreshes the cached zone information then returns the cool setpoint"""
    self.refresh_zone_info()
    return int(self.zone_info["latestData"]["uiData"]["CoolSetpoint"])
def get_current_temperature(self) ‑> str

calls get_current_temperature_raw() then adds on a degree sign and the display unit

Expand source code
def get_current_temperature(self) -> str:
    """calls get_current_temperature_raw() then adds on a degree sign and the display unit"""
    raw = self.get_current_temperature_raw()
    return self._get_with_unit(raw)
def get_current_temperature_raw(self) ‑> int

gets the current temperature via refreshing the cached zone information

Expand source code
def get_current_temperature_raw(self) -> int:
    """gets the current temperature via refreshing the cached zone information"""
    self.refresh_zone_info()
    if self.zone_info["DispTempAvailable"]:
        return int(self.zone_info["DispTemp"])

    raise KeyError("Temperature is unavailable")
def get_fan_mode(self) ‑> FanMode

refreshes the cached zone information then returns the current FanMode

Expand source code
def get_fan_mode(self) -> FanMode:
    """
    refreshes the cached zone information then returns the current FanMode
    """
    self.refresh_zone_info()
    return FanMode(self.zone_info["latestData"]["fanData"]["fanMode"])
def get_heat_setpoint(self) ‑> str

calls get_heat_setpoint_raw() then adds on a degree sign and the display unit

Expand source code
def get_heat_setpoint(self) -> str:
    """calls get_heat_setpoint_raw() then adds on a degree sign and the display unit"""
    raw = self.get_heat_setpoint_raw()
    return self._get_with_unit(raw)
def get_heat_setpoint_raw(self) ‑> int

refreshes the cached zone information then returns the heat setpoint

Expand source code
def get_heat_setpoint_raw(self) -> int:
    """refreshes the cached zone information then returns the heat setpoint"""
    self.refresh_zone_info()
    return int(self.zone_info["latestData"]["uiData"]["HeatSetpoint"])
def get_indoor_humidity(self) ‑> str

calls get_indoor_humidity_raw() then returns it with a % display unit

Expand source code
def get_indoor_humidity(self) -> str:
    """calls get_indoor_humidity_raw() then returns it with a % display unit"""
    raw = self.get_indoor_humidity_raw()
    return str(raw) + str("%")
def get_indoor_humidity_raw(self) ‑> int

refreshes the cached zone information then returns the indoor humidity raw value

Expand source code
def get_indoor_humidity_raw(self) -> int:
    """refreshes the cached zone information then returns the indoor humidity raw value"""
    self.refresh_zone_info()
    return self.zone_info["latestData"]["uiData"]["IndoorHumidity"]
def get_indoor_temperature(self) ‑> str

calls get_indoor_temperature_raw() then returns it with a degree sign and the Display unit

Expand source code
def get_indoor_temperature(self) -> str:
    """calls get_indoor_temperature_raw() then returns it with a degree sign and the Display unit"""
    raw = self.get_indoor_temperature_raw()
    return self._get_with_unit(raw)
def get_indoor_temperature_raw(self) ‑> int

refreshes the cached zone information then returns the indoor temperature raw value

Expand source code
def get_indoor_temperature_raw(self) -> int:
    """refreshes the cached zone information then returns the indoor temperature raw value"""
    self.refresh_zone_info()
    return self.zone_info["latestData"]["uiData"]["DispTemperature"]
def get_name(self) ‑> str

gets the name corresponding with this Zone

Expand source code
def get_name(self) -> str:
    """gets the name corresponding with this Zone"""
    return self.zone_info["Name"]
def get_outdoor_temperature(self) ‑> str

calls get_outdoor_temperature_raw() then returns it with a degree sign and the display unit

Expand source code
def get_outdoor_temperature(self) -> str:
    """calls get_outdoor_temperature_raw() then returns it with a degree sign and the display unit"""
    raw = self.get_outdoor_temperature_raw()
    return self._get_with_unit(raw)
def get_outdoor_temperature_raw(self) ‑> int

refreshes the cached zone information then returns the outdoor temperature raw value

Expand source code
def get_outdoor_temperature_raw(self) -> int:
    """refreshes the cached zone information then returns the outdoor temperature raw value"""
    self.refresh_zone_info()
    return self.zone_info["OutdoorTemperature"]
def get_system_mode(self) ‑> SystemMode

refreshes the cached zone information then returns the current system mode

Expand source code
def get_system_mode(self) -> SystemMode:
    """
    refreshes the cached zone information then returns the current system mode
    """
    self.refresh_zone_info()
    return SystemMode(
        self.zone_info["latestData"]["uiData"]["SystemSwitchPosition"]
    )
def is_calling_for_cool(self) ‑> int

Refreshes the cached zone information and checks if the system mode is cooling

Expand source code
def is_calling_for_cool(self) -> int:
    """
    Refreshes the cached zone information and checks if the system mode is cooling
    """
    return (
        self.get_system_mode() in (SystemMode.Cool, SystemMode.AutoCool)
        and self.is_equipment_output_on()
    )
def is_calling_for_heat(self) ‑> int

Refreshes the cached zone information and checks if the system mode is heating

Expand source code
def is_calling_for_heat(self) -> int:
    """
    Refreshes the cached zone information and checks if the system mode is heating
    """
    return (
        self.get_system_mode()
        in (SystemMode.Heat, SystemMode.AutoHeat, SystemMode.EMHeat)
        and self.is_equipment_output_on()
    )
def is_equipment_output_on(self) ‑> bool

Refreshes the cached zone information then Returns true if the EquipmentOutputStatus is non 0. This typically meansthe system is heating/cooling.

Expand source code
def is_equipment_output_on(self) -> bool:
    """
    Refreshes the cached zone information then Returns true if the EquipmentOutputStatus
    is non 0. This typically meansthe system is heating/cooling.
    """
    self.refresh_zone_info()
    return bool(self.zone_info["latestData"]["uiData"]["EquipmentOutputStatus"])
def is_fan_running(self) ‑> bool

refreshes the cached zone information then returns True if the fan is running

Expand source code
def is_fan_running(self) -> bool:
    """
    refreshes the cached zone information then returns True if the fan is running
    """
    self.refresh_zone_info()
    return bool(self.zone_info["latestData"]["fanData"]["fanIsRunning"])
def refresh_zone_info(self) ‑> None

refreshes the zone_info attribute

Expand source code
def refresh_zone_info(self) -> None:
    """refreshes the zone_info attribute"""
    all_zones_info = self.pyhtcc.get_zones_info()
    for z in all_zones_info:
        if z["DeviceID"] == self.device_id:
            logger.debug("Refreshed zone info for {self.device_id}")
            self.zone_info = z
            return

    raise ZoneNotFoundError(f"Missing device: {self.device_id}")
def set_permananent_cool_setpoint(self, temp: int) ‑> None

deprecated… this is a misspelling of set_permanent_cool_setpoint()

Expand source code
@deprecated(
    version="0.1.11",
    reason="Use the correctly spelt: set_permanent_cool_setpoint() instead. set_permananent_cool_setpoint() will be removed in a future release.",
)
def set_permananent_cool_setpoint(self, temp: int) -> None:
    """deprecated... this is a misspelling of set_permanent_cool_setpoint()"""
    return self.set_permanent_cool_setpoint(temp)
def set_permananent_heat_setpoint(self, temp: int) ‑> None

deprecated… this is a misspelling of set_permanent_heat_setpoint()

Expand source code
@deprecated(
    version="0.1.11",
    reason="Use the correctly spelt: set_permanent_heat_setpoint() instead. set_permananent_heat_setpoint() will be removed in a future release.",
)
def set_permananent_heat_setpoint(self, temp: int) -> None:
    """deprecated... this is a misspelling of set_permanent_heat_setpoint()"""
    return self.set_permanent_heat_setpoint(temp)
def set_permanent_cool_setpoint(self, temp: int) ‑> None

Sets a new permanent cool setpoint. This will also attempt to turn the thermostat to 'Cool'

Expand source code
def set_permanent_cool_setpoint(self, temp: int) -> None:
    """
    Sets a new permanent cool setpoint.
    This will also attempt to turn the thermostat to 'Cool'
    """
    logger.info(f"setting cool on with a target temp of: {temp}")
    return self.submit_control_changes(
        {"CoolSetpoint": temp, "StatusHeat": 2, "StatusCool": 2, "SystemSwitch": 3}
    )
def set_permanent_heat_setpoint(self, temp: int) ‑> None

Sets a new permanent heat setpoint. This will also attempt to turn the thermostat to 'Heat'

Expand source code
def set_permanent_heat_setpoint(self, temp: int) -> None:
    """
    Sets a new permanent heat setpoint.
    This will also attempt to turn the thermostat to 'Heat'
    """
    logger.info(f"setting heat on with a target temp of: {temp}")
    return self.submit_control_changes(
        {
            "HeatSetpoint": temp,
            "StatusHeat": 2,
            "StatusCool": 2,
            "SystemSwitch": 1,
        }
    )
def set_temp_cool_setpoint(self, temp: int, end: typing.Union[datetime.timedelta, datetime.time, None] = None) ‑> None

Sets a new temporary cool setpoint. This will also attempt to turn the thermostat to 'Cool'

If you provide an 'end' it should be either: - A datetime.timedelta for less than 24 hours from now OR - A datetime.time for a specific time of day (within the next 24 hours) OR - None corresponding with 'the thermostat will pick an end time'

The end will automatically be rounded to the nearest 15 minute mark.

Expand source code
def set_temp_cool_setpoint(
    self,
    temp: int,
    end: typing.Union[datetime.timedelta, datetime.time, None] = None,
) -> None:
    """
    Sets a new temporary cool setpoint.
    This will also attempt to turn the thermostat to 'Cool'

    If you provide an 'end' it should be either:
        - A datetime.timedelta for less than 24 hours from now
        OR
        - A datetime.time for a specific time of day (within the next 24 hours)
        OR
        - None corresponding with 'the thermostat will pick an end time'

    The end will automatically be rounded to the nearest 15 minute mark.
    """
    logger.info(f"setting temp heat on with a target temp of: {temp}")
    return self.submit_control_changes(
        {
            "CoolSetpoint": temp,
            "StatusHeat": 1,
            "StatusCool": 1,
            "SystemSwitch": 3,
            "CoolNextPeriod": self._coerce_temp_end_to_setpoint(end),
        }
    )
def set_temp_heat_setpoint(self, temp: int, end: typing.Union[datetime.timedelta, datetime.time, None] = None) ‑> None

Sets a new temporary heat setpoint. This will also attempt to turn the thermostat to 'Heat'

If you provide an 'end' it should be either: - A datetime.timedelta for less than 24 hours from now OR - A datetime.time for a specific time of day (within the next 24 hours) OR - None corresponding with 'the thermostat will pick an end time'

The end will automatically be rounded to the nearest 15 minute mark.

Expand source code
def set_temp_heat_setpoint(
    self,
    temp: int,
    end: typing.Union[datetime.timedelta, datetime.time, None] = None,
) -> None:
    """
    Sets a new temporary heat setpoint.
    This will also attempt to turn the thermostat to 'Heat'

    If you provide an 'end' it should be either:
        - A datetime.timedelta for less than 24 hours from now
        OR
        - A datetime.time for a specific time of day (within the next 24 hours)
        OR
        - None corresponding with 'the thermostat will pick an end time'

    The end will automatically be rounded to the nearest 15 minute mark.
    """
    logger.info(f"setting temp heat on with a target temp of: {temp}")
    return self.submit_control_changes(
        {
            "HeatSetpoint": temp,
            "StatusHeat": 1,
            "StatusCool": 1,
            "SystemSwitch": 1,
            "HeatNextPeriod": self._coerce_temp_end_to_setpoint(end),
        }
    )
def submit_control_changes(self, data: dict) ‑> None

This is a low-level API call to PyHTCC.submit_raw_control_changes(). More likely than not, most users need not use this call directly.

Expand source code
def submit_control_changes(self, data: dict) -> None:
    """
    This is a low-level API call to PyHTCC.submit_raw_control_changes().
    More likely than not, most users need not use this call directly.
    """
    return self.pyhtcc.submit_raw_control_changes(self.device_id, data)
def turn_fan_auto(self) ‑> None

turns the fan to auto

Expand source code
def turn_fan_auto(self) -> None:
    """turns the fan to auto"""
    logger.info("turning fan to auto")
    return self.submit_control_changes(
        {
            "FanMode": 0,
        }
    )
def turn_fan_circulate(self) ‑> None

turns the fan to circulate

Expand source code
def turn_fan_circulate(self) -> None:
    """turns the fan to circulate"""
    logger.info("turning fan circulate")
    return self.submit_control_changes(
        {
            "FanMode": 2,
        }
    )
def turn_fan_on(self) ‑> None

turns the fan on

Expand source code
def turn_fan_on(self) -> None:
    """turns the fan on"""
    logger.info("turning fan on")
    return self.submit_control_changes(
        {
            "FanMode": 1,
        }
    )
def turn_system_off(self) ‑> None

turns this thermostat off

Expand source code
def turn_system_off(self) -> None:
    """turns this thermostat off"""
    logger.info("turning system off")
    return self.submit_control_changes(
        {
            "SystemSwitch": 2,
        }
    )
class ZoneNotFoundError (*args, **kwargs)

raised if the zone could not be found on refresh

Expand source code
class ZoneNotFoundError(EnvironmentError):
    """raised if the zone could not be found on refresh"""

    pass

Ancestors

  • builtins.OSError
  • builtins.Exception
  • builtins.BaseException