From 0217ebdf8c93dd43c3fde4c05fa56102c9dd07cc Mon Sep 17 00:00:00 2001 From: Matt Turner Date: Thu, 14 Dec 2023 22:08:16 +0000 Subject: [PATCH 01/55] Refactor to extract web client --- .gitignore | 2 + README.md | 10 +- config.sample.py | 8 + custom_components/salusfy/climate.py | 246 +----------------- custom_components/salusfy/state.py | 8 + .../salusfy/thermostat_entity.py | 134 ++++++++++ custom_components/salusfy/web_client.py | 128 +++++++++ run.py | 17 ++ 8 files changed, 314 insertions(+), 239 deletions(-) create mode 100644 .gitignore create mode 100644 config.sample.py create mode 100644 custom_components/salusfy/state.py create mode 100644 custom_components/salusfy/thermostat_entity.py create mode 100644 custom_components/salusfy/web_client.py create mode 100644 run.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..94f973f --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +config.py +*.pyc \ No newline at end of file diff --git a/README.md b/README.md index 19cb21f..afbfd75 100644 --- a/README.md +++ b/README.md @@ -2,13 +2,15 @@ Custom Components for Home-Assistant (http://www.home-assistant.io) # Salus Thermostat Climate Component -My device is RT301i, it is working with it500 thermostat, the ideea is simple if you have a Salus Thermostat and you are able to login to salus-it500.com and controll it from this page, this custom component should work. -Component to interface with the salus-it500.com. +My device is RT301i, it is working with it500 thermostat, the ideea is simple if you have a Salus Thermostat and you are able to login to salus-it500.com and control it from this page, this custom component should work. + +## Component to interface with the salus-it500.com. It reads the Current Temperature, Set Temperature, Current HVAC Mode, Current Relay Mode. Keep in mind this is my first custom component and this is also the first version of this Salusfy so it can have bugs. Sorry for that. **** This is not an official integration. + ### Installation * If not exist, in config/custom_components/ create a directory called salusfy * Copy all files in salusfy to your config/custom_components/salusfy/ directory. @@ -32,7 +34,7 @@ climate: ### Getting the DEVICEID -1. Loggin to https://salus-it500.com with email and password used in the mobile app(in my case RT301i) +1. Loggin to https://salus-it500.com with email and password used in the mobile app (in my case RT301i) 2. Click on the device 3. In the next page you will be able to see the device ID in the page URL 4. Copy the device ID from the URL @@ -40,4 +42,4 @@ climate: ### Known issues -salus-it500.com server is bloking the IP of the host, in our case the HA external IP. This can be fixed with router restart in case of PPOE connection or you can try to send a mail to salus support... +Due to how chatty the HA integration is, the salus-it500.com server may start blocking your public IP address (and rightly so). This will prevent the gateway and mobile client from connecting. This implementation aims to resolve this by suppressing requests in many circumstances. The effect of this is that the current temperature value will be out of date but the main control features (target temperature, set status etc) will still work. \ No newline at end of file diff --git a/config.sample.py b/config.sample.py new file mode 100644 index 0000000..7181c86 --- /dev/null +++ b/config.sample.py @@ -0,0 +1,8 @@ +# this file is used by the run.py test script +# it is not required by the custom component + +# copy this file to config.py and replace the values + +USERNAME = "replace" +PASSWORD = "replace" +DEVICE_ID = "replace" \ No newline at end of file diff --git a/custom_components/salusfy/climate.py b/custom_components/salusfy/climate.py index 4b21476..2affb5c 100644 --- a/custom_components/salusfy/climate.py +++ b/custom_components/salusfy/climate.py @@ -1,61 +1,30 @@ """ Adds support for the Salus Thermostat units. """ -import datetime -import time import logging -import re -import requests -import json import homeassistant.helpers.config_validation as cv import voluptuous as vol -from homeassistant.components.climate.const import ( - CURRENT_HVAC_HEAT, - CURRENT_HVAC_IDLE, - HVAC_MODE_HEAT, - HVAC_MODE_OFF, - SUPPORT_TARGET_TEMPERATURE, -) + from homeassistant.const import ( - ATTR_TEMPERATURE, CONF_PASSWORD, CONF_USERNAME, CONF_ID, - TEMP_CELSIUS, ) -try: - from homeassistant.components.climate import ( - ClimateEntity, - PLATFORM_SCHEMA, - ) -except ImportError: - from homeassistant.components.climate import ( - ClimateDevice as ClimateEntity, - PLATFORM_SCHEMA, - ) +from custom_components.salusfy.thermostat_entity import ThermostatEntity +from custom_components.salusfy.web_client import WebClient + +from homeassistant.components.climate import PLATFORM_SCHEMA __version__ = "0.0.1" _LOGGER = logging.getLogger(__name__) -URL_LOGIN = "https://salus-it500.com/public/login.php" -URL_GET_TOKEN = "https://salus-it500.com/public/control.php" -URL_GET_DATA = "https://salus-it500.com/public/ajax_device_values.php" -URL_SET_DATA = "https://salus-it500.com/includes/set.php" - DEFAULT_NAME = "Salus Thermostat" - CONF_NAME = "name" -# Values from web interface -MIN_TEMP = 5 -MAX_TEMP = 34.5 - -SUPPORT_FLAGS = SUPPORT_TARGET_TEMPERATURE - PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -68,209 +37,16 @@ def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the E-Thermostaat platform.""" + """Set up the E-Thermostat platform.""" name = config.get(CONF_NAME) username = config.get(CONF_USERNAME) password = config.get(CONF_PASSWORD) id = config.get(CONF_ID) - add_entities( - [SalusThermostat(name, username, password, id)] - ) - - -class SalusThermostat(ClimateEntity): - """Representation of a Salus Thermostat device.""" - - def __init__(self, name, username, password, id): - """Initialize the thermostat.""" - self._name = name - self._username = username - self._password = password - self._id = id - self._current_temperature = None - self._target_temperature = None - self._frost = None - self._status = None - self._current_operation_mode = None - self._token = None - - self._session = requests.Session() - - - self.update() - - @property - def supported_features(self): - """Return the list of supported features.""" - return SUPPORT_FLAGS - - @property - def name(self): - """Return the name of the thermostat.""" - return self._name - - @property - def unique_id(self) -> str: - """Return the unique ID for this thermostat.""" - return "_".join([self._name, "climate"]) - - @property - def should_poll(self): - """Return if polling is required.""" - return True + _LOGGER.info('Registering SalusThermostat climate entity...') - @property - def min_temp(self): - """Return the minimum temperature.""" - return MIN_TEMP - - @property - def max_temp(self): - """Return the maximum temperature.""" - return MAX_TEMP - - @property - def temperature_unit(self): - """Return the unit of measurement.""" - return TEMP_CELSIUS - - @property - def current_temperature(self): - """Return the current temperature.""" - return self._current_temperature - - @property - def target_temperature(self): - """Return the temperature we try to reach.""" - return self._target_temperature - - - @property - def hvac_mode(self): - """Return hvac operation ie. heat, cool mode.""" - try: - climate_mode = self._current_operation_mode - curr_hvac_mode = HVAC_MODE_OFF - if climate_mode == "ON": - curr_hvac_mode = HVAC_MODE_HEAT - else: - curr_hvac_mode = HVAC_MODE_OFF - except KeyError: - return HVAC_MODE_OFF - return curr_hvac_mode - - @property - def hvac_modes(self): - """HVAC modes.""" - return [HVAC_MODE_HEAT, HVAC_MODE_OFF] - - @property - def hvac_action(self): - """Return the current running hvac operation.""" - if self._status == "ON": - return CURRENT_HVAC_HEAT - return CURRENT_HVAC_IDLE - - - @property - def preset_mode(self): - """Return the current preset mode, e.g., home, away, temp.""" - return self._status - - @property - def preset_modes(self): - """Return a list of available preset modes.""" - return SUPPORT_PRESET - - - def set_temperature(self, **kwargs): - """Set new target temperature.""" - temperature = kwargs.get(ATTR_TEMPERATURE) - if temperature is None: - return - self._set_temperature(temperature) - - def _set_temperature(self, temperature): - """Set new target temperature, via URL commands.""" - payload = {"token": self._token, "devId": self._id, "tempUnit": "0", "current_tempZ1_set": "1", "current_tempZ1": temperature} - headers = {"content-type": "application/x-www-form-urlencoded"} - try: - if self._session.post(URL_SET_DATA, data=payload, headers=headers): - self._target_temperature = temperature - # self.schedule_update_ha_state(force_refresh=True) - _LOGGER.info("Salusfy set_temperature OK") - except: - _LOGGER.error("Error Setting the temperature.") - - def set_hvac_mode(self, hvac_mode): - """Set HVAC mode, via URL commands.""" - - headers = {"content-type": "application/x-www-form-urlencoded"} - if hvac_mode == HVAC_MODE_OFF: - payload = {"token": self._token, "devId": self._id, "auto": "1", "auto_setZ1": "1"} - try: - if self._session.post(URL_SET_DATA, data=payload, headers=headers): - self._current_operation_mode = "OFF" - except: - _LOGGER.error("Error Setting HVAC mode OFF.") - elif hvac_mode == HVAC_MODE_HEAT: - payload = {"token": self._token, "devId": self._id, "auto": "0", "auto_setZ1": "1"} - try: - if self._session.post(URL_SET_DATA, data=payload, headers=headers): - self._current_operation_mode = "ON" - except: - _LOGGER.error("Error Setting HVAC mode.") - _LOGGER.info("Setting the HVAC mode.") - - def get_token(self): - """Get the Session Token of the Thermostat.""" - payload = {"IDemail": self._username, "password": self._password, "login": "Login", "keep_logged_in": "1"} - headers = {"content-type": "application/x-www-form-urlencoded"} - - try: - self._session.post(URL_LOGIN, data=payload, headers=headers) - params = {"devId": self._id} - getTkoken = self._session.get(URL_GET_TOKEN,params=params) - result = re.search('', getTkoken.text) - _LOGGER.info("Salusfy get_token OK") - self._token = result.group(1) - except: - _LOGGER.error("Error Geting the Session Token.") - - def _get_data(self): - if self._token is None: - self.get_token() - params = {"devId": self._id, "token": self._token, "&_": str(int(round(time.time() * 1000)))} - try: - r = self._session.get(url = URL_GET_DATA, params = params) - try: - if r: - data = json.loads(r.text) - _LOGGER.info("Salusfy get_data output "+r.text) - self._target_temperature = float(data["CH1currentSetPoint"]) - self._current_temperature = float(data["CH1currentRoomTemp"]) - self._frost = float(data["frost"]) - - status = data['CH1heatOnOffStatus'] - if status == "1": - self._status = "ON" - else: - self._status = "OFF" - mode = data['CH1heatOnOff'] - if mode == "1": - self._current_operation_mode = "OFF" - else: - self._current_operation_mode = "ON" - else: - _LOGGER.error("Could not get data from Salus.") - except: - self.get_token() - self._get_data() - except: - _LOGGER.error("Error Geting the data from Web. Please check the connection to salus-it500.com manually.") - - def update(self): - """Get the latest data.""" - self._get_data() + web_client = WebClient(username, password, id) + add_entities( + [ThermostatEntity(name, web_client)] + ) \ No newline at end of file diff --git a/custom_components/salusfy/state.py b/custom_components/salusfy/state.py new file mode 100644 index 0000000..f484717 --- /dev/null +++ b/custom_components/salusfy/state.py @@ -0,0 +1,8 @@ +class State: + """The state of the thermostat.""" + def __init__(self): + self.current_temperature = None + self.target_temperature = None + self.frost = None + self.status = None + self.current_operation_mode = None \ No newline at end of file diff --git a/custom_components/salusfy/thermostat_entity.py b/custom_components/salusfy/thermostat_entity.py new file mode 100644 index 0000000..db77cfa --- /dev/null +++ b/custom_components/salusfy/thermostat_entity.py @@ -0,0 +1,134 @@ +import logging + +from custom_components.salusfy.web_client import ( + WebClient, + STATE_ON +) + +from homeassistant.components.climate.const import ( + CURRENT_HVAC_HEAT, + CURRENT_HVAC_IDLE, + HVAC_MODE_HEAT, + HVAC_MODE_OFF, + SUPPORT_PRESET_MODE, + SUPPORT_TARGET_TEMPERATURE +) + +from homeassistant.const import ( + ATTR_TEMPERATURE, + TEMP_CELSIUS, +) + +try: + from homeassistant.components.climate import ClimateEntity +except ImportError: + from homeassistant.components.climate import ClimateDevice as ClimateEntity + +SUPPORT_FLAGS = SUPPORT_TARGET_TEMPERATURE + +class ThermostatEntity(ClimateEntity): + """Representation of a Salus Thermostat device.""" + + def __init__(self, name, client): + """Initialize the thermostat.""" + self._name = name + self._client = client + self._state = None + + self.update() + + @property + def supported_features(self): + """Return the list of supported features.""" + return SUPPORT_FLAGS + + @property + def name(self): + """Return the name of the thermostat.""" + return self._name + + @property + def unique_id(self) -> str: + """Return the unique ID for this thermostat.""" + return "_".join([self._name, "climate"]) + + @property + def should_poll(self): + """Return if polling is required.""" + return True + + @property + def min_temp(self): + """Return the minimum temperature.""" + return self._client.MIN_TEMP + + @property + def max_temp(self): + """Return the maximum temperature.""" + return self._client.MAX_TEMP + + @property + def temperature_unit(self): + """Return the unit of measurement.""" + return TEMP_CELSIUS + + @property + def current_temperature(self): + """Return the current temperature.""" + return self._state.current_temperature + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + return self._state.target_temperature + + + @property + def hvac_mode(self): + """Return hvac operation ie. heat, cool mode.""" + try: + climate_mode = self._state.current_operation_mode + curr_hvac_mode = HVAC_MODE_OFF + if climate_mode == STATE_ON: + curr_hvac_mode = HVAC_MODE_HEAT + else: + curr_hvac_mode = HVAC_MODE_OFF + except KeyError: + return HVAC_MODE_OFF + return curr_hvac_mode + + @property + def hvac_modes(self): + """HVAC modes.""" + return [HVAC_MODE_HEAT, HVAC_MODE_OFF] + + @property + def hvac_action(self): + """Return the current running hvac operation.""" + if self._state.status == STATE_ON: + return CURRENT_HVAC_HEAT + return CURRENT_HVAC_IDLE + + + @property + def preset_mode(self): + """Return the current preset mode, e.g., home, away, temp.""" + return self._state.status + + @property + def preset_modes(self): + """Return a list of available preset modes.""" + return SUPPORT_PRESET_MODE + + + def set_temperature(self, **kwargs): + """Set new target temperature.""" + temperature = kwargs.get(ATTR_TEMPERATURE) + if temperature is None: + return + self._client.set_temperature(temperature) + + + def update(self): + """Get the latest state data.""" + self._state = self._client.get_state() \ No newline at end of file diff --git a/custom_components/salusfy/web_client.py b/custom_components/salusfy/web_client.py new file mode 100644 index 0000000..8de79fd --- /dev/null +++ b/custom_components/salusfy/web_client.py @@ -0,0 +1,128 @@ +""" +Adds support for the Salus Thermostat units. +""" +import time +import logging +import re +import requests +import json + +from custom_components.salusfy.state import State + +HVAC_MODE_HEAT = "heat" +HVAC_MODE_OFF = "off" + +STATE_ON = "ON" +STATE_OFF = "OFF" + +_LOGGER = logging.getLogger(__name__) + +URL_LOGIN = "https://salus-it500.com/public/login.php" +URL_GET_TOKEN = "https://salus-it500.com/public/control.php" +URL_GET_DATA = "https://salus-it500.com/public/ajax_device_values.php" +URL_SET_DATA = "https://salus-it500.com/includes/set.php" + +# Values from web interface +MIN_TEMP = 5 +MAX_TEMP = 34.5 + +class WebClient: + """Adapter around Salus IT500 web application.""" + + def __init__(self, username, password, id): + """Initialize the client.""" + self._username = username + self._password = password + self._id = id + self._token = None + + self._session = requests.Session() + + + def set_temperature(self, temperature): + """Set new target temperature, via URL commands.""" + payload = {"token": self._token, "devId": self._id, "tempUnit": "0", "current_tempZ1_set": "1", "current_tempZ1": temperature} + headers = {"content-type": "application/x-www-form-urlencoded"} + try: + self._session.post(URL_SET_DATA, data=payload, headers=headers) + _LOGGER.info("Salusfy set_temperature OK") + except: + _LOGGER.error("Error Setting the temperature.") + + + def set_hvac_mode(self, hvac_mode): + """Set HVAC mode, via URL commands.""" + + headers = {"content-type": "application/x-www-form-urlencoded"} + if hvac_mode == HVAC_MODE_OFF: + payload = {"token": self._token, "devId": self._id, "auto": "1", "auto_setZ1": "1"} + try: + self._session.post(URL_SET_DATA, data=payload, headers=headers) + except: + _LOGGER.error("Error Setting HVAC mode OFF.") + elif hvac_mode == HVAC_MODE_HEAT: + payload = {"token": self._token, "devId": self._id, "auto": "0", "auto_setZ1": "1"} + try: + self._session.post(URL_SET_DATA, data=payload, headers=headers) + except: + _LOGGER.error("Error Setting HVAC mode.") + + _LOGGER.info("Setting the HVAC mode.") + + + def get_token(self): + """Get the Session Token of the Thermostat.""" + payload = {"IDemail": self._username, "password": self._password, "login": "Login", "keep_logged_in": "1"} + headers = {"content-type": "application/x-www-form-urlencoded"} + + try: + self._session.post(URL_LOGIN, data=payload, headers=headers) + params = {"devId": self._id} + getTkoken = self._session.get(URL_GET_TOKEN,params=params) + result = re.search('', getTkoken.text) + _LOGGER.info("Salusfy get_token OK") + self._token = result.group(1) + except: + _LOGGER.error("Error getting the session token.") + + + def get_state(self): + if self._token is None: + self.get_token() + + params = {"devId": self._id, "token": self._token, "&_": str(int(round(time.time() * 1000)))} + try: + r = self._session.get(url = URL_GET_DATA, params = params) + if not r: + _LOGGER.error("Could not get data from Salus.") + return None + except: + _LOGGER.error("Error Getting the data from Web. Please check the connection to salus-it500.com manually.") + return None + + try: + data = json.loads(r.text) + _LOGGER.info("Salusfy get_data output " + r.text) + + state = State() + state.target_temperature = float(data["CH1currentSetPoint"]) + state.current_temperature = float(data["CH1currentRoomTemp"]) + state.frost = float(data["frost"]) + + status = data['CH1heatOnOffStatus'] + if status == "1": + state.status = STATE_ON + else: + state.status = STATE_OFF + + mode = data['CH1heatOnOff'] + if mode == "1": + state.current_operation_mode = STATE_OFF + else: + state.current_operation_mode = STATE_ON + + return state + except: + self.get_token() + return self.get_state() + diff --git a/run.py b/run.py new file mode 100644 index 0000000..1f6804d --- /dev/null +++ b/run.py @@ -0,0 +1,17 @@ +# To run this script to test the component: +# 1 Copy config.sample.py to config.py +# 2 Replace the username/password/deviceid (don't worry, this file will be ignored by git) +# 3 Run with `python run.py` + +from custom_components.salusfy.thermostat_entity import ThermostatEntity +from custom_components.salusfy.web_client import WebClient + +import config + +client = WebClient(config.USERNAME, config.PASSWORD, config.DEVICE_ID) +thermostat = ThermostatEntity("thermostat", client) + +print("Current: " + str(thermostat.current_temperature)) +print("Target: " + str(thermostat.target_temperature)) +print("HVAC Action: " + thermostat.hvac_action) +print("HVAC Mode: " + thermostat.hvac_mode) \ No newline at end of file From b5294091c0782479ea98bb2ba4b5470636eeda46 Mon Sep 17 00:00:00 2001 From: Matt Turner Date: Thu, 14 Dec 2023 22:32:55 +0000 Subject: [PATCH 02/55] Add mock web client --- custom_components/salusfy/climate.py | 18 ++++++-- custom_components/salusfy/manifest.json | 8 ++-- custom_components/salusfy/mock_web_client.py | 45 +++++++++++++++++++ .../salusfy/thermostat_entity.py | 1 - run.py | 4 ++ 5 files changed, 68 insertions(+), 8 deletions(-) create mode 100644 custom_components/salusfy/mock_web_client.py diff --git a/custom_components/salusfy/climate.py b/custom_components/salusfy/climate.py index 2affb5c..e5a162d 100644 --- a/custom_components/salusfy/climate.py +++ b/custom_components/salusfy/climate.py @@ -14,6 +14,7 @@ from custom_components.salusfy.thermostat_entity import ThermostatEntity from custom_components.salusfy.web_client import WebClient +from custom_components.salusfy.mock_web_client import MockWebClient from homeassistant.components.climate import PLATFORM_SCHEMA @@ -25,6 +26,8 @@ CONF_NAME = "name" +CONF_SIMULATOR = 'simulator' + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -32,6 +35,7 @@ vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string, vol.Required(CONF_ID): cv.string, + vol.Optional(CONF_SIMULATOR, default=False): cv.boolean } ) @@ -42,11 +46,17 @@ def setup_platform(hass, config, add_entities, discovery_info=None): username = config.get(CONF_USERNAME) password = config.get(CONF_PASSWORD) id = config.get(CONF_ID) + simulator = config.get(CONF_SIMULATOR) _LOGGER.info('Registering SalusThermostat climate entity...') - web_client = WebClient(username, password, id) + if (simulator): + add_entities( + [ThermostatEntity(name, MockWebClient())] + ) + else: + web_client = WebClient(username, password, id) - add_entities( - [ThermostatEntity(name, web_client)] - ) \ No newline at end of file + add_entities( + [ThermostatEntity(name, web_client)] + ) \ No newline at end of file diff --git a/custom_components/salusfy/manifest.json b/custom_components/salusfy/manifest.json index 84b6d13..7ade128 100644 --- a/custom_components/salusfy/manifest.json +++ b/custom_components/salusfy/manifest.json @@ -1,11 +1,13 @@ { "domain": "salusfy", "name": "Salus thermostat", - "version": "0.0.1", + "version": "0.1.0", "documentation": "https://github.com/floringhimie/salusfy", "issue_tracker": "https://github.com/floringhimie/salusfy/issues", "requirements": [], "dependencies": [], - "codeowners": ["@floringhimie"], + "codeowners": [ + "@floringhimie" + ], "iot_class": "cloud_polling" -} +} \ No newline at end of file diff --git a/custom_components/salusfy/mock_web_client.py b/custom_components/salusfy/mock_web_client.py new file mode 100644 index 0000000..14992d5 --- /dev/null +++ b/custom_components/salusfy/mock_web_client.py @@ -0,0 +1,45 @@ +""" +Adds support for the Salus Thermostat units. +""" +import logging + +from custom_components.salusfy.state import State +from custom_components.salusfy.web_client import ( + HVAC_MODE_HEAT, + HVAC_MODE_OFF, + STATE_ON, + STATE_OFF +) + + +_LOGGER = logging.getLogger(__name__) + +class MockWebClient: + """Mocks requests to Salus web application""" + + def __init__(self): + """Initialize the client.""" + self._state = State() + self._state.target_temperature = 20 + self._state.current_temperature = 15 + self._state.frost = 10 + + + def set_temperature(self, temperature): + """Set new target temperature.""" + self._state.target_temperature = temperature + _LOGGER.info("Salusfy set_temperature OK") + + + def set_hvac_mode(self, hvac_mode): + """Set HVAC mode, via URL commands.""" + if hvac_mode == HVAC_MODE_OFF: + self._state.current_operation_mode = STATE_OFF + elif hvac_mode == HVAC_MODE_HEAT: + self._state.current_operation_mode = STATE_ON + _LOGGER.info("Setting the HVAC mode.") + + + def get_state(self): + """Retrieves the mock status""" + return self._state diff --git a/custom_components/salusfy/thermostat_entity.py b/custom_components/salusfy/thermostat_entity.py index db77cfa..911f0dd 100644 --- a/custom_components/salusfy/thermostat_entity.py +++ b/custom_components/salusfy/thermostat_entity.py @@ -1,7 +1,6 @@ import logging from custom_components.salusfy.web_client import ( - WebClient, STATE_ON ) diff --git a/run.py b/run.py index 1f6804d..b8a6b46 100644 --- a/run.py +++ b/run.py @@ -5,10 +5,14 @@ from custom_components.salusfy.thermostat_entity import ThermostatEntity from custom_components.salusfy.web_client import WebClient +from custom_components.salusfy.mock_web_client import MockWebClient import config +# choose either client +# client = MockWebClient() client = WebClient(config.USERNAME, config.PASSWORD, config.DEVICE_ID) + thermostat = ThermostatEntity("thermostat", client) print("Current: " + str(thermostat.current_temperature)) From 8fe06a84ff368c48bc85050d4e8f805c5ae15007 Mon Sep 17 00:00:00 2001 From: Matt Turner Date: Fri, 15 Dec 2023 07:35:49 +0000 Subject: [PATCH 03/55] Fix import order --- custom_components/salusfy/__init__.py | 11 +++++ custom_components/salusfy/climate.py | 11 +++-- custom_components/salusfy/mock_web_client.py | 4 +- install.sh | 4 ++ run.py | 46 +++++++++++++++++--- 5 files changed, 61 insertions(+), 15 deletions(-) create mode 100644 install.sh diff --git a/custom_components/salusfy/__init__.py b/custom_components/salusfy/__init__.py index cced37c..e7b4cfe 100644 --- a/custom_components/salusfy/__init__.py +++ b/custom_components/salusfy/__init__.py @@ -1 +1,12 @@ """The Salus component.""" + +from .state import State +from .web_client import ( + WebClient, + HVAC_MODE_HEAT, + HVAC_MODE_OFF, + STATE_ON, + STATE_OFF +) +from .mock_web_client import MockWebClient +from .thermostat_entity import ThermostatEntity \ No newline at end of file diff --git a/custom_components/salusfy/climate.py b/custom_components/salusfy/climate.py index e5a162d..106e015 100644 --- a/custom_components/salusfy/climate.py +++ b/custom_components/salusfy/climate.py @@ -12,9 +12,9 @@ CONF_ID, ) -from custom_components.salusfy.thermostat_entity import ThermostatEntity -from custom_components.salusfy.web_client import WebClient -from custom_components.salusfy.mock_web_client import MockWebClient +CONF_SIMULATOR = 'simulator' + +from . import ( ThermostatEntity, WebClient, MockWebClient ) from homeassistant.components.climate import PLATFORM_SCHEMA @@ -26,7 +26,6 @@ CONF_NAME = "name" -CONF_SIMULATOR = 'simulator' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( @@ -48,13 +47,13 @@ def setup_platform(hass, config, add_entities, discovery_info=None): id = config.get(CONF_ID) simulator = config.get(CONF_SIMULATOR) - _LOGGER.info('Registering SalusThermostat climate entity...') - if (simulator): + _LOGGER.info('Registering Salus simulator...') add_entities( [ThermostatEntity(name, MockWebClient())] ) else: + _LOGGER.info('Registering Salus Thermostat climate entity...') web_client = WebClient(username, password, id) add_entities( diff --git a/custom_components/salusfy/mock_web_client.py b/custom_components/salusfy/mock_web_client.py index 14992d5..a60acd9 100644 --- a/custom_components/salusfy/mock_web_client.py +++ b/custom_components/salusfy/mock_web_client.py @@ -3,8 +3,8 @@ """ import logging -from custom_components.salusfy.state import State -from custom_components.salusfy.web_client import ( +from . import ( + State, HVAC_MODE_HEAT, HVAC_MODE_OFF, STATE_ON, diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..c1cf0e5 --- /dev/null +++ b/install.sh @@ -0,0 +1,4 @@ + +# expects the repository to be cloned within the homeassistant directory + +cp ./custom_components/salusfy/*.* ../custom_components/salusfy \ No newline at end of file diff --git a/run.py b/run.py index b8a6b46..9d04a5d 100644 --- a/run.py +++ b/run.py @@ -3,17 +3,49 @@ # 2 Replace the username/password/deviceid (don't worry, this file will be ignored by git) # 3 Run with `python run.py` -from custom_components.salusfy.thermostat_entity import ThermostatEntity -from custom_components.salusfy.web_client import WebClient -from custom_components.salusfy.mock_web_client import MockWebClient +from custom_components.salusfy import climate import config -# choose either client -# client = MockWebClient() -client = WebClient(config.USERNAME, config.PASSWORD, config.DEVICE_ID) +class ConfigAdapter: + def __init__(self, config): + self._config = config -thermostat = ThermostatEntity("thermostat", client) + + def get(self, key): + if (key == 'name'): + return 'Simulator' + + if (key == 'id'): + return self._config.DEVICE_ID + + if (key == 'username'): + return self._config.USERNAME + + if (key == 'password'): + return self._config.PASSWORD + + if (key == 'simulator'): + return self._config.SIMULATOR + + +class EntityRegistry: + def __init__(self): + self._entities = [] + + def register(self, list): + self._entities.extend(list) + + def first(self): + return self._entities[0] + + +registry = EntityRegistry() +config_adapter = ConfigAdapter(config) + +climate.setup_platform(None, config_adapter, add_entities=registry.register, discovery_info=None) + +thermostat = registry.first() print("Current: " + str(thermostat.current_temperature)) print("Target: " + str(thermostat.target_temperature)) From f9f21f4c979388ccb600a0cd8edaeefa77c61be5 Mon Sep 17 00:00:00 2001 From: Matt Turner Date: Fri, 15 Dec 2023 08:01:23 +0000 Subject: [PATCH 04/55] Fix min/max temp --- custom_components/salusfy/thermostat_entity.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/custom_components/salusfy/thermostat_entity.py b/custom_components/salusfy/thermostat_entity.py index 911f0dd..e78ea0a 100644 --- a/custom_components/salusfy/thermostat_entity.py +++ b/custom_components/salusfy/thermostat_entity.py @@ -1,7 +1,9 @@ import logging from custom_components.salusfy.web_client import ( - STATE_ON + STATE_ON, + MAX_TEMP, + MIN_TEMP ) from homeassistant.components.climate.const import ( @@ -59,12 +61,12 @@ def should_poll(self): @property def min_temp(self): """Return the minimum temperature.""" - return self._client.MIN_TEMP + return MIN_TEMP @property def max_temp(self): """Return the maximum temperature.""" - return self._client.MAX_TEMP + return MAX_TEMP @property def temperature_unit(self): From 36cf9df58f5e87cd2b4d28dee706c3637d50ec6d Mon Sep 17 00:00:00 2001 From: Matt Turner Date: Fri, 15 Dec 2023 20:51:51 +0000 Subject: [PATCH 05/55] Create HA connection --- README.md | 2 ++ custom_components/salusfy/__init__.py | 3 ++- custom_components/salusfy/climate.py | 19 ++++++++++++----- custom_components/salusfy/ha_web_client.py | 21 +++++++++++++++++++ .../salusfy/thermostat_entity.py | 21 ++++++++++++++++--- run.py | 14 +++++++++++++ 6 files changed, 71 insertions(+), 9 deletions(-) create mode 100644 custom_components/salusfy/ha_web_client.py diff --git a/README.md b/README.md index afbfd75..53eb9c5 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,8 @@ climate: username: "EMAIL" password: "PASSWORD" id: "DEVICEID" + entity_id: "sensor.temperature" + access_token: "ha_long_lived_token" ``` ![image](https://user-images.githubusercontent.com/33951255/140300295-4915a18f-f5d4-4957-b513-59d7736cc52a.png) ![image](https://user-images.githubusercontent.com/33951255/140303472-fd38b9e4-5c33-408f-afef-25547c39551c.png) diff --git a/custom_components/salusfy/__init__.py b/custom_components/salusfy/__init__.py index e7b4cfe..db3557a 100644 --- a/custom_components/salusfy/__init__.py +++ b/custom_components/salusfy/__init__.py @@ -9,4 +9,5 @@ STATE_OFF ) from .mock_web_client import MockWebClient -from .thermostat_entity import ThermostatEntity \ No newline at end of file +from .thermostat_entity import ThermostatEntity +from .ha_web_client import HaWebClient \ No newline at end of file diff --git a/custom_components/salusfy/climate.py b/custom_components/salusfy/climate.py index 106e015..8ba2034 100644 --- a/custom_components/salusfy/climate.py +++ b/custom_components/salusfy/climate.py @@ -10,15 +10,18 @@ CONF_PASSWORD, CONF_USERNAME, CONF_ID, + CONF_ENTITY_ID, + CONF_ACCESS_TOKEN, + CONF_HOST ) CONF_SIMULATOR = 'simulator' -from . import ( ThermostatEntity, WebClient, MockWebClient ) +from . import ( ThermostatEntity, WebClient, MockWebClient, HaWebClient ) from homeassistant.components.climate import PLATFORM_SCHEMA -__version__ = "0.0.1" +__version__ = "0.1.0" _LOGGER = logging.getLogger(__name__) @@ -34,7 +37,8 @@ vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string, vol.Required(CONF_ID): cv.string, - vol.Optional(CONF_SIMULATOR, default=False): cv.boolean + vol.Optional(CONF_SIMULATOR, default=False): cv.boolean, + vol.Optional(CONF_HOST, default='localhost'): cv.string } ) @@ -46,16 +50,21 @@ def setup_platform(hass, config, add_entities, discovery_info=None): password = config.get(CONF_PASSWORD) id = config.get(CONF_ID) simulator = config.get(CONF_SIMULATOR) + entity_id = config.get(CONF_ENTITY_ID) + host = config.get(CONF_HOST) + access_token = config.get(CONF_ACCESS_TOKEN) + + ha_client = HaWebClient(host, entity_id, access_token) if (simulator): _LOGGER.info('Registering Salus simulator...') add_entities( - [ThermostatEntity(name, MockWebClient())] + [ThermostatEntity(name, MockWebClient(), ha_client)] ) else: _LOGGER.info('Registering Salus Thermostat climate entity...') web_client = WebClient(username, password, id) add_entities( - [ThermostatEntity(name, web_client)] + [ThermostatEntity(name, web_client, ha_client)] ) \ No newline at end of file diff --git a/custom_components/salusfy/ha_web_client.py b/custom_components/salusfy/ha_web_client.py new file mode 100644 index 0000000..7b576c4 --- /dev/null +++ b/custom_components/salusfy/ha_web_client.py @@ -0,0 +1,21 @@ +from requests import get + +class HaWebClient: + def __init__(self, host, entity_id, access_token): + self._entity_id = entity_id + self._host = host + self._access_token = access_token + + + def current_temperature(self): + """Gets the current temperature from """ + + url = F"http://{self._host}:8123/api/states/{self._entity_id}" + + headers = { + "Authorization": F"Bearer {self._access_token}", + "Content-Type": "application/json", + } + + response = get(url, headers=headers) + return float(response.json()['state']) \ No newline at end of file diff --git a/custom_components/salusfy/thermostat_entity.py b/custom_components/salusfy/thermostat_entity.py index e78ea0a..4b4b2db 100644 --- a/custom_components/salusfy/thermostat_entity.py +++ b/custom_components/salusfy/thermostat_entity.py @@ -2,6 +2,7 @@ from custom_components.salusfy.web_client import ( STATE_ON, + STATE_OFF, MAX_TEMP, MIN_TEMP ) @@ -30,10 +31,11 @@ class ThermostatEntity(ClimateEntity): """Representation of a Salus Thermostat device.""" - def __init__(self, name, client): + def __init__(self, name, client, ha_client): """Initialize the thermostat.""" self._name = name self._client = client + self._ha_client = ha_client self._state = None self.update() @@ -76,7 +78,7 @@ def temperature_unit(self): @property def current_temperature(self): """Return the current temperature.""" - return self._state.current_temperature + return self._ha_client.current_temperature() @property def target_temperature(self): @@ -128,8 +130,21 @@ def set_temperature(self, **kwargs): if temperature is None: return self._client.set_temperature(temperature) + self._state.target_temperature = temperature + def set_hvac_mode(self, hvac_mode): + """Set HVAC mode, via URL commands.""" + if hvac_mode == HVAC_MODE_OFF: + self._state.current_operation_mode = STATE_OFF + self._state.status = STATE_OFF + elif hvac_mode == HVAC_MODE_HEAT: + self._state.current_operation_mode = STATE_ON + self._state.status = STATE_ON + self._client.set_hvac_mode(hvac_mode) + + def update(self): """Get the latest state data.""" - self._state = self._client.get_state() \ No newline at end of file + if self._state is None: + self._state = self._client.get_state() \ No newline at end of file diff --git a/run.py b/run.py index 9d04a5d..fe899bb 100644 --- a/run.py +++ b/run.py @@ -28,6 +28,15 @@ def get(self, key): if (key == 'simulator'): return self._config.SIMULATOR + if (key == 'host'): + return self._config.HOST + + if (key == 'entity_id'): + return self._config.ENTITY_ID + + if (key == 'access_token'): + return self._config.ACCESS_TOKEN + class EntityRegistry: def __init__(self): @@ -47,6 +56,11 @@ def first(self): thermostat = registry.first() +thermostat.update() +thermostat.update() + +thermostat.set_hvac_mode('off') + print("Current: " + str(thermostat.current_temperature)) print("Target: " + str(thermostat.target_temperature)) print("HVAC Action: " + thermostat.hvac_action) From 8acac2853d9a0886f66367cef00c75515cd6c46f Mon Sep 17 00:00:00 2001 From: Matt Turner Date: Fri, 15 Dec 2023 20:59:38 +0000 Subject: [PATCH 06/55] Add config values --- custom_components/salusfy/climate.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/custom_components/salusfy/climate.py b/custom_components/salusfy/climate.py index 8ba2034..01348f3 100644 --- a/custom_components/salusfy/climate.py +++ b/custom_components/salusfy/climate.py @@ -38,6 +38,8 @@ vol.Required(CONF_PASSWORD): cv.string, vol.Required(CONF_ID): cv.string, vol.Optional(CONF_SIMULATOR, default=False): cv.boolean, + vol.Required(CONF_ENTITY_ID): cv.string, + vol.Required(CONF_ACCESS_TOKEN): cv.string, vol.Optional(CONF_HOST, default='localhost'): cv.string } ) From 7cba43543b8836a1cd6b719f40e998b0486a3f93 Mon Sep 17 00:00:00 2001 From: Matt Turner Date: Fri, 15 Dec 2023 21:39:50 +0000 Subject: [PATCH 07/55] Improved handling when starting up --- custom_components/salusfy/ha_web_client.py | 12 +++++++++++- custom_components/salusfy/thermostat_entity.py | 5 +++-- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/custom_components/salusfy/ha_web_client.py b/custom_components/salusfy/ha_web_client.py index 7b576c4..577187f 100644 --- a/custom_components/salusfy/ha_web_client.py +++ b/custom_components/salusfy/ha_web_client.py @@ -18,4 +18,14 @@ def current_temperature(self): } response = get(url, headers=headers) - return float(response.json()['state']) \ No newline at end of file + + body = response.json() + + if 'state' not in body: + return None + + state = body['state'] + if state == 'unavailable': + return None + + return float(state) \ No newline at end of file diff --git a/custom_components/salusfy/thermostat_entity.py b/custom_components/salusfy/thermostat_entity.py index 4b4b2db..f86b9dc 100644 --- a/custom_components/salusfy/thermostat_entity.py +++ b/custom_components/salusfy/thermostat_entity.py @@ -78,7 +78,7 @@ def temperature_unit(self): @property def current_temperature(self): """Return the current temperature.""" - return self._ha_client.current_temperature() + return self._state.current_temperature @property def target_temperature(self): @@ -147,4 +147,5 @@ def set_hvac_mode(self, hvac_mode): def update(self): """Get the latest state data.""" if self._state is None: - self._state = self._client.get_state() \ No newline at end of file + self._state = self._client.get_state() + self._state.current_temperature = self._ha_client.current_temperature() \ No newline at end of file From 2efb2fba115b4f124f1b05d40e8ed6cb45be9db0 Mon Sep 17 00:00:00 2001 From: Matt Turner Date: Fri, 15 Dec 2023 21:45:00 +0000 Subject: [PATCH 08/55] Improve readme --- README.md | 11 ++++++++--- custom_components/salusfy/manifest.json | 2 +- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 53eb9c5..09cd426 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ climate: - platform: salusfy username: "EMAIL" password: "PASSWORD" - id: "DEVICEID" + id: "DEVICE_ID" entity_id: "sensor.temperature" access_token: "ha_long_lived_token" ``` @@ -35,7 +35,7 @@ climate: ![image](https://user-images.githubusercontent.com/33951255/140303472-fd38b9e4-5c33-408f-afef-25547c39551c.png) -### Getting the DEVICEID +### Getting the DEVICE_ID 1. Loggin to https://salus-it500.com with email and password used in the mobile app (in my case RT301i) 2. Click on the device 3. In the next page you will be able to see the device ID in the page URL @@ -44,4 +44,9 @@ climate: ### Known issues -Due to how chatty the HA integration is, the salus-it500.com server may start blocking your public IP address (and rightly so). This will prevent the gateway and mobile client from connecting. This implementation aims to resolve this by suppressing requests in many circumstances. The effect of this is that the current temperature value will be out of date but the main control features (target temperature, set status etc) will still work. \ No newline at end of file +Due to how chatty the HA integration is, the salus-it500.com server may start blocking your public IP address (and rightly so). This will prevent the gateway and mobile client from connecting. This implementation aims to resolve this by: + +* suppressing requests to Salus in many circumstances +* querying another entity for current temperature + +The effect of this is that the target temperature/status values may be out of date if it has been outside of HA, but the main control features (target temperature, set status etc) will still work. \ No newline at end of file diff --git a/custom_components/salusfy/manifest.json b/custom_components/salusfy/manifest.json index 7ade128..d483edf 100644 --- a/custom_components/salusfy/manifest.json +++ b/custom_components/salusfy/manifest.json @@ -1,6 +1,6 @@ { "domain": "salusfy", - "name": "Salus thermostat", + "name": "Salus Thermostat", "version": "0.1.0", "documentation": "https://github.com/floringhimie/salusfy", "issue_tracker": "https://github.com/floringhimie/salusfy/issues", From 868de9c0ef1a2e534f2db2f2bfe87f46eb26cc39 Mon Sep 17 00:00:00 2001 From: Matt Turner Date: Sat, 16 Dec 2023 16:52:11 +0000 Subject: [PATCH 09/55] Reuse cached token for 10 mins --- custom_components/salusfy/mock_web_client.py | 8 +- .../salusfy/thermostat_entity.py | 8 +- custom_components/salusfy/web_client.py | 120 +++++++++++------- run.py | 11 +- 4 files changed, 99 insertions(+), 48 deletions(-) diff --git a/custom_components/salusfy/mock_web_client.py b/custom_components/salusfy/mock_web_client.py index a60acd9..aa5f0d3 100644 --- a/custom_components/salusfy/mock_web_client.py +++ b/custom_components/salusfy/mock_web_client.py @@ -27,17 +27,21 @@ def __init__(self): def set_temperature(self, temperature): """Set new target temperature.""" + + _LOGGER.info("Setting temperature to %.1f...", temperature) + self._state.target_temperature = temperature - _LOGGER.info("Salusfy set_temperature OK") def set_hvac_mode(self, hvac_mode): """Set HVAC mode, via URL commands.""" + + _LOGGER.info("Setting the HVAC mode to %s...", hvac_mode) + if hvac_mode == HVAC_MODE_OFF: self._state.current_operation_mode = STATE_OFF elif hvac_mode == HVAC_MODE_HEAT: self._state.current_operation_mode = STATE_ON - _LOGGER.info("Setting the HVAC mode.") def get_state(self): diff --git a/custom_components/salusfy/thermostat_entity.py b/custom_components/salusfy/thermostat_entity.py index f86b9dc..3dc9780 100644 --- a/custom_components/salusfy/thermostat_entity.py +++ b/custom_components/salusfy/thermostat_entity.py @@ -126,22 +126,28 @@ def preset_modes(self): def set_temperature(self, **kwargs): """Set new target temperature.""" + temperature = kwargs.get(ATTR_TEMPERATURE) + if temperature is None: return + self._client.set_temperature(temperature) + self._state.target_temperature = temperature def set_hvac_mode(self, hvac_mode): """Set HVAC mode, via URL commands.""" + + self._client.set_hvac_mode(hvac_mode) + if hvac_mode == HVAC_MODE_OFF: self._state.current_operation_mode = STATE_OFF self._state.status = STATE_OFF elif hvac_mode == HVAC_MODE_HEAT: self._state.current_operation_mode = STATE_ON self._state.status = STATE_ON - self._client.set_hvac_mode(hvac_mode) def update(self): diff --git a/custom_components/salusfy/web_client.py b/custom_components/salusfy/web_client.py index 8de79fd..de1ba1d 100644 --- a/custom_components/salusfy/web_client.py +++ b/custom_components/salusfy/web_client.py @@ -25,6 +25,7 @@ # Values from web interface MIN_TEMP = 5 MAX_TEMP = 34.5 +MAX_TOKEN_AGE_SECONDS = 60 * 10 class WebClient: """Adapter around Salus IT500 web application.""" @@ -35,17 +36,24 @@ def __init__(self, username, password, id): self._password = password self._id = id self._token = None + self._tokenRetrievedAt = None self._session = requests.Session() def set_temperature(self, temperature): """Set new target temperature, via URL commands.""" - payload = {"token": self._token, "devId": self._id, "tempUnit": "0", "current_tempZ1_set": "1", "current_tempZ1": temperature} - headers = {"content-type": "application/x-www-form-urlencoded"} + + _LOGGER.info("Setting the temperature to %.1f...", temperature) + + token = self.obtain_token() + + payload = {"token": token, "devId": self._id, "tempUnit": "0", "current_tempZ1_set": "1", "current_tempZ1": temperature} + headers = {"Content-Type": "application/x-www-form-urlencoded"} + try: self._session.post(URL_SET_DATA, data=payload, headers=headers) - _LOGGER.info("Salusfy set_temperature OK") + _LOGGER.info("Salusfy set_temperature: OK") except: _LOGGER.error("Error Setting the temperature.") @@ -53,44 +61,72 @@ def set_temperature(self, temperature): def set_hvac_mode(self, hvac_mode): """Set HVAC mode, via URL commands.""" - headers = {"content-type": "application/x-www-form-urlencoded"} + _LOGGER.info("Setting the HVAC mode to %s...", hvac_mode) + + headers = {"Content-Type": "application/x-www-form-urlencoded"} + + auto = "1" if hvac_mode == HVAC_MODE_OFF: - payload = {"token": self._token, "devId": self._id, "auto": "1", "auto_setZ1": "1"} - try: - self._session.post(URL_SET_DATA, data=payload, headers=headers) - except: - _LOGGER.error("Error Setting HVAC mode OFF.") + auto = "1" elif hvac_mode == HVAC_MODE_HEAT: - payload = {"token": self._token, "devId": self._id, "auto": "0", "auto_setZ1": "1"} - try: - self._session.post(URL_SET_DATA, data=payload, headers=headers) - except: - _LOGGER.error("Error Setting HVAC mode.") + auto = "0" + + token = self.obtain_token() + + payload = {"token": token, "devId": self._id, "auto": auto, "auto_setZ1": "1"} + try: + self._session.post(URL_SET_DATA, data=payload, headers=headers) + except: + _LOGGER.error("Error Setting HVAC mode to %s", hvac_mode) + + + def obtain_token(self): + """Gets the existing session token of the thermostat or retrieves a new one if expired.""" - _LOGGER.info("Setting the HVAC mode.") + if self._token is None: + _LOGGER.info("Retrieving token for the first time this session...") + self.get_token() + return self._token + + if self._tokenRetrievedAt > time.time() - MAX_TOKEN_AGE_SECONDS: + _LOGGER.info("Using cached token...") + return self._token + + _LOGGER.info("Token has expired, getting new one...") + self.get_token() + return self._token def get_token(self): """Get the Session Token of the Thermostat.""" + + _LOGGER.info("Getting token from Salus...") + payload = {"IDemail": self._username, "password": self._password, "login": "Login", "keep_logged_in": "1"} - headers = {"content-type": "application/x-www-form-urlencoded"} + headers = {"Content-Type": "application/x-www-form-urlencoded"} try: self._session.post(URL_LOGIN, data=payload, headers=headers) params = {"devId": self._id} - getTkoken = self._session.get(URL_GET_TOKEN,params=params) + getTkoken = self._session.get(URL_GET_TOKEN, params=params) result = re.search('', getTkoken.text) _LOGGER.info("Salusfy get_token OK") self._token = result.group(1) + self._tokenRetrievedAt = time.time() except: + self._token = None + self._tokenRetrievedAt = None _LOGGER.error("Error getting the session token.") def get_state(self): - if self._token is None: - self.get_token() + """Retrieve the current state from the Salus gateway""" + + _LOGGER.info("Retrieving current state from Salus Gateway...") + + token = self.obtain_token() - params = {"devId": self._id, "token": self._token, "&_": str(int(round(time.time() * 1000)))} + params = {"devId": self._id, "token": token, "&_": str(int(round(time.time() * 1000)))} try: r = self._session.get(url = URL_GET_DATA, params = params) if not r: @@ -100,29 +136,25 @@ def get_state(self): _LOGGER.error("Error Getting the data from Web. Please check the connection to salus-it500.com manually.") return None - try: - data = json.loads(r.text) - _LOGGER.info("Salusfy get_data output " + r.text) + data = json.loads(r.text) + _LOGGER.info("Salusfy get_data output " + r.text) - state = State() - state.target_temperature = float(data["CH1currentSetPoint"]) - state.current_temperature = float(data["CH1currentRoomTemp"]) - state.frost = float(data["frost"]) - - status = data['CH1heatOnOffStatus'] - if status == "1": - state.status = STATE_ON - else: - state.status = STATE_OFF - - mode = data['CH1heatOnOff'] - if mode == "1": - state.current_operation_mode = STATE_OFF - else: - state.current_operation_mode = STATE_ON - - return state - except: - self.get_token() - return self.get_state() + state = State() + state.target_temperature = float(data["CH1currentSetPoint"]) + state.current_temperature = float(data["CH1currentRoomTemp"]) + state.frost = float(data["frost"]) + + status = data['CH1heatOnOffStatus'] + if status == "1": + state.status = STATE_ON + else: + state.status = STATE_OFF + + mode = data['CH1heatOnOff'] + if mode == "1": + state.current_operation_mode = STATE_OFF + else: + state.current_operation_mode = STATE_ON + + return state diff --git a/run.py b/run.py index fe899bb..08dc881 100644 --- a/run.py +++ b/run.py @@ -5,8 +5,16 @@ from custom_components.salusfy import climate +from homeassistant.components.climate.const import ( + HVAC_MODE_HEAT, + HVAC_MODE_OFF, +) + import config +import logging +logging.basicConfig(format='%(levelname)s: %(message)s', level=logging.DEBUG) + class ConfigAdapter: def __init__(self, config): self._config = config @@ -59,7 +67,8 @@ def first(self): thermostat.update() thermostat.update() -thermostat.set_hvac_mode('off') +thermostat.set_hvac_mode(HVAC_MODE_HEAT) +thermostat.set_temperature(temperature=9.8) print("Current: " + str(thermostat.current_temperature)) print("Target: " + str(thermostat.target_temperature)) From d89d1efc042c9de5908c213e3b10cb9be5e70acc Mon Sep 17 00:00:00 2001 From: Matt Turner Date: Sat, 16 Dec 2023 16:57:35 +0000 Subject: [PATCH 10/55] Improve output of install script --- install.sh | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/install.sh b/install.sh index c1cf0e5..12a0454 100644 --- a/install.sh +++ b/install.sh @@ -1,4 +1,8 @@ # expects the repository to be cloned within the homeassistant directory -cp ./custom_components/salusfy/*.* ../custom_components/salusfy \ No newline at end of file +echo "Copying all files from the cloned repo to the Home Assistant custom_components directory..." + +cp --verbose ./custom_components/salusfy/*.* ../custom_components/salusfy + +echo "Restart Home Assistant to apply the changes" \ No newline at end of file From 96e4964dad9046ccdc6d41b4af9c39001715a987 Mon Sep 17 00:00:00 2001 From: Matt Turner Date: Sat, 16 Dec 2023 21:24:23 +0000 Subject: [PATCH 11/55] Add unit tests (#1) --- .github/workflows/ci.yml | 37 +++++++++++++++ Dockerfile | 12 +++++ install.sh | 2 +- requirements.test.txt | 2 + requirements.txt | 1 + run.py | 46 ++----------------- .../salusfy => salusfy}/__init__.py | 3 +- .../salusfy => salusfy}/climate.py | 8 ++-- .../salusfy => salusfy}/ha_web_client.py | 0 .../salusfy => salusfy}/manifest.json | 0 salusfy/mock_ha_web_client.py | 7 +++ .../salusfy => salusfy}/mock_web_client.py | 0 .../salusfy => salusfy}/state.py | 0 .../salusfy => salusfy}/thermostat_entity.py | 2 +- .../salusfy => salusfy}/web_client.py | 2 +- test.ps1 | 5 ++ tests/__init__.py | 0 tests/config_adapter.py | 29 ++++++++++++ tests/entity_registry.py | 13 ++++++ tests/mock_config.py | 7 +++ tests/test_climate.py | 15 ++++++ 21 files changed, 139 insertions(+), 52 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 Dockerfile create mode 100644 requirements.test.txt create mode 100644 requirements.txt rename {custom_components/salusfy => salusfy}/__init__.py (74%) rename {custom_components/salusfy => salusfy}/climate.py (91%) rename {custom_components/salusfy => salusfy}/ha_web_client.py (100%) rename {custom_components/salusfy => salusfy}/manifest.json (100%) create mode 100644 salusfy/mock_ha_web_client.py rename {custom_components/salusfy => salusfy}/mock_web_client.py (100%) rename {custom_components/salusfy => salusfy}/state.py (100%) rename {custom_components/salusfy => salusfy}/thermostat_entity.py (98%) rename {custom_components/salusfy => salusfy}/web_client.py (99%) create mode 100644 test.ps1 create mode 100644 tests/__init__.py create mode 100644 tests/config_adapter.py create mode 100644 tests/entity_registry.py create mode 100644 tests/mock_config.py create mode 100644 tests/test_climate.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3dae740 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,37 @@ +name: CI + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +permissions: + contents: read + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + - name: Set up Python 3.10 + uses: actions/setup-python@v3 + with: + python-version: "3.10" + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install flake8 pytest + if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + if [ -f requirements.test.txt ]; then pip install -r requirements.test.txt; fi + - name: Lint with flake8 + run: | + # stop the build if there are Python syntax errors or undefined names + flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics + # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide + flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + - name: Test with pytest + run: | + pytest \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..2baad14 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,12 @@ +FROM python:3.9.10 +ENV PIP_DISABLE_ROOT_WARNING=1 +RUN python -m pip install --upgrade pip +COPY requirements.test.txt requirements.test.txt +RUN pip install -r requirements.test.txt +COPY requirements.txt requirements.txt +RUN pip install -r requirements.txt +VOLUME /app +WORKDIR /app +ENV NO_COLOR=yes_please +ENV LANG=C +CMD ["python", "-m", "pytest"] \ No newline at end of file diff --git a/install.sh b/install.sh index 12a0454..03f73b8 100644 --- a/install.sh +++ b/install.sh @@ -3,6 +3,6 @@ echo "Copying all files from the cloned repo to the Home Assistant custom_components directory..." -cp --verbose ./custom_components/salusfy/*.* ../custom_components/salusfy +cp --verbose ./salusfy/*.* ../custom_components/salusfy echo "Restart Home Assistant to apply the changes" \ No newline at end of file diff --git a/requirements.test.txt b/requirements.test.txt new file mode 100644 index 0000000..1709c74 --- /dev/null +++ b/requirements.test.txt @@ -0,0 +1,2 @@ +pytest +pytest-homeassistant-custom-component \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..f229360 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +requests diff --git a/run.py b/run.py index 08dc881..90e7116 100644 --- a/run.py +++ b/run.py @@ -3,7 +3,9 @@ # 2 Replace the username/password/deviceid (don't worry, this file will be ignored by git) # 3 Run with `python run.py` -from custom_components.salusfy import climate +from salusfy import climate +from tests.config_adapter import ConfigAdapter +from tests.entity_registry import EntityRegistry from homeassistant.components.climate.const import ( HVAC_MODE_HEAT, @@ -15,48 +17,6 @@ import logging logging.basicConfig(format='%(levelname)s: %(message)s', level=logging.DEBUG) -class ConfigAdapter: - def __init__(self, config): - self._config = config - - - def get(self, key): - if (key == 'name'): - return 'Simulator' - - if (key == 'id'): - return self._config.DEVICE_ID - - if (key == 'username'): - return self._config.USERNAME - - if (key == 'password'): - return self._config.PASSWORD - - if (key == 'simulator'): - return self._config.SIMULATOR - - if (key == 'host'): - return self._config.HOST - - if (key == 'entity_id'): - return self._config.ENTITY_ID - - if (key == 'access_token'): - return self._config.ACCESS_TOKEN - - -class EntityRegistry: - def __init__(self): - self._entities = [] - - def register(self, list): - self._entities.extend(list) - - def first(self): - return self._entities[0] - - registry = EntityRegistry() config_adapter = ConfigAdapter(config) diff --git a/custom_components/salusfy/__init__.py b/salusfy/__init__.py similarity index 74% rename from custom_components/salusfy/__init__.py rename to salusfy/__init__.py index db3557a..d0d847a 100644 --- a/custom_components/salusfy/__init__.py +++ b/salusfy/__init__.py @@ -10,4 +10,5 @@ ) from .mock_web_client import MockWebClient from .thermostat_entity import ThermostatEntity -from .ha_web_client import HaWebClient \ No newline at end of file +from .ha_web_client import HaWebClient +from .mock_ha_web_client import MockHaWebClient \ No newline at end of file diff --git a/custom_components/salusfy/climate.py b/salusfy/climate.py similarity index 91% rename from custom_components/salusfy/climate.py rename to salusfy/climate.py index 01348f3..918f849 100644 --- a/custom_components/salusfy/climate.py +++ b/salusfy/climate.py @@ -17,7 +17,7 @@ CONF_SIMULATOR = 'simulator' -from . import ( ThermostatEntity, WebClient, MockWebClient, HaWebClient ) +from . import ( ThermostatEntity, WebClient, MockWebClient, HaWebClient, MockHaWebClient ) from homeassistant.components.climate import PLATFORM_SCHEMA @@ -30,7 +30,6 @@ CONF_NAME = "name" - PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, @@ -56,16 +55,15 @@ def setup_platform(hass, config, add_entities, discovery_info=None): host = config.get(CONF_HOST) access_token = config.get(CONF_ACCESS_TOKEN) - ha_client = HaWebClient(host, entity_id, access_token) - if (simulator): _LOGGER.info('Registering Salus simulator...') add_entities( - [ThermostatEntity(name, MockWebClient(), ha_client)] + [ThermostatEntity(name, MockWebClient(), MockHaWebClient())] ) else: _LOGGER.info('Registering Salus Thermostat climate entity...') web_client = WebClient(username, password, id) + ha_client = HaWebClient(host, entity_id, access_token) add_entities( [ThermostatEntity(name, web_client, ha_client)] diff --git a/custom_components/salusfy/ha_web_client.py b/salusfy/ha_web_client.py similarity index 100% rename from custom_components/salusfy/ha_web_client.py rename to salusfy/ha_web_client.py diff --git a/custom_components/salusfy/manifest.json b/salusfy/manifest.json similarity index 100% rename from custom_components/salusfy/manifest.json rename to salusfy/manifest.json diff --git a/salusfy/mock_ha_web_client.py b/salusfy/mock_ha_web_client.py new file mode 100644 index 0000000..91a6834 --- /dev/null +++ b/salusfy/mock_ha_web_client.py @@ -0,0 +1,7 @@ + +class MockHaWebClient: + def __init__(self): + pass + + def current_temperature(self): + return 15.9 \ No newline at end of file diff --git a/custom_components/salusfy/mock_web_client.py b/salusfy/mock_web_client.py similarity index 100% rename from custom_components/salusfy/mock_web_client.py rename to salusfy/mock_web_client.py diff --git a/custom_components/salusfy/state.py b/salusfy/state.py similarity index 100% rename from custom_components/salusfy/state.py rename to salusfy/state.py diff --git a/custom_components/salusfy/thermostat_entity.py b/salusfy/thermostat_entity.py similarity index 98% rename from custom_components/salusfy/thermostat_entity.py rename to salusfy/thermostat_entity.py index 3dc9780..d9fc715 100644 --- a/custom_components/salusfy/thermostat_entity.py +++ b/salusfy/thermostat_entity.py @@ -1,6 +1,6 @@ import logging -from custom_components.salusfy.web_client import ( +from .web_client import ( STATE_ON, STATE_OFF, MAX_TEMP, diff --git a/custom_components/salusfy/web_client.py b/salusfy/web_client.py similarity index 99% rename from custom_components/salusfy/web_client.py rename to salusfy/web_client.py index de1ba1d..e0b0742 100644 --- a/custom_components/salusfy/web_client.py +++ b/salusfy/web_client.py @@ -7,7 +7,7 @@ import requests import json -from custom_components.salusfy.state import State +from .state import State HVAC_MODE_HEAT = "heat" HVAC_MODE_OFF = "off" diff --git a/test.ps1 b/test.ps1 new file mode 100644 index 0000000..0d77805 --- /dev/null +++ b/test.ps1 @@ -0,0 +1,5 @@ +# use this to run test suite on windows hosts + +docker build -t salusfy . + +docker run -it -v .:/app salusfy \ No newline at end of file diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/config_adapter.py b/tests/config_adapter.py new file mode 100644 index 0000000..55ea6b2 --- /dev/null +++ b/tests/config_adapter.py @@ -0,0 +1,29 @@ +class ConfigAdapter: + def __init__(self, config): + self._config = config + + + def get(self, key): + if (key == 'name'): + return 'Simulator' + + if (key == 'id'): + return self._config.DEVICE_ID + + if (key == 'username'): + return self._config.USERNAME + + if (key == 'password'): + return self._config.PASSWORD + + if (key == 'simulator'): + return self._config.SIMULATOR + + if (key == 'host'): + return self._config.HOST + + if (key == 'entity_id'): + return self._config.ENTITY_ID + + if (key == 'access_token'): + return self._config.ACCESS_TOKEN \ No newline at end of file diff --git a/tests/entity_registry.py b/tests/entity_registry.py new file mode 100644 index 0000000..fb653cb --- /dev/null +++ b/tests/entity_registry.py @@ -0,0 +1,13 @@ +class EntityRegistry: + def __init__(self): + self._entities = [] + + def register(self, list): + self._entities.extend(list) + + @property + def entities(self): + return self._entities + + def first(self): + return self._entities[0] \ No newline at end of file diff --git a/tests/mock_config.py b/tests/mock_config.py new file mode 100644 index 0000000..3d4e823 --- /dev/null +++ b/tests/mock_config.py @@ -0,0 +1,7 @@ +USERNAME = "john@smith.com" +PASSWORD = "12345" +DEVICE_ID = "999999" +ENTITY_ID = "sensor.everything_presence_one_temperature" +ACCESS_TOKEN = "some-secret" +SIMULATOR = True +HOST = "192.168.0.99" \ No newline at end of file diff --git a/tests/test_climate.py b/tests/test_climate.py new file mode 100644 index 0000000..c8d1767 --- /dev/null +++ b/tests/test_climate.py @@ -0,0 +1,15 @@ +import pytest + +from salusfy import climate +from .config_adapter import ConfigAdapter +from .entity_registry import EntityRegistry + +from . import mock_config + + +def test_entity_is_registered(): + registry = EntityRegistry() + config_adapter = ConfigAdapter(mock_config) + climate.setup_platform(None, config_adapter, add_entities=registry.register, discovery_info=None) + + assert len(registry.entities) == 1 \ No newline at end of file From f431633602d29ecc48533e92f49c05a8d1a73e6d Mon Sep 17 00:00:00 2001 From: Matt Turner Date: Sun, 17 Dec 2023 07:50:52 +0000 Subject: [PATCH 12/55] Add thermostat entity unit tests (#2) --- .gitignore | 3 ++- requirements.test.txt | 1 + run.py | 2 +- salusfy/mock_web_client.py | 6 +++--- tests/entity_registry.py | 1 + tests/test_climate.py | 25 +++++++++++++++++++++++-- tests/test_thermostat_entity.py | 30 ++++++++++++++++++++++++++++++ 7 files changed, 61 insertions(+), 7 deletions(-) create mode 100644 tests/test_thermostat_entity.py diff --git a/.gitignore b/.gitignore index 94f973f..9da8b8c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ config.py -*.pyc \ No newline at end of file +*.pyc +__pycache__ \ No newline at end of file diff --git a/requirements.test.txt b/requirements.test.txt index 1709c74..2c79089 100644 --- a/requirements.test.txt +++ b/requirements.test.txt @@ -1,2 +1,3 @@ pytest +pytest-mock pytest-homeassistant-custom-component \ No newline at end of file diff --git a/run.py b/run.py index 90e7116..360311c 100644 --- a/run.py +++ b/run.py @@ -22,7 +22,7 @@ climate.setup_platform(None, config_adapter, add_entities=registry.register, discovery_info=None) -thermostat = registry.first() +thermostat = registry.first thermostat.update() thermostat.update() diff --git a/salusfy/mock_web_client.py b/salusfy/mock_web_client.py index aa5f0d3..967d8a2 100644 --- a/salusfy/mock_web_client.py +++ b/salusfy/mock_web_client.py @@ -20,9 +20,9 @@ class MockWebClient: def __init__(self): """Initialize the client.""" self._state = State() - self._state.target_temperature = 20 - self._state.current_temperature = 15 - self._state.frost = 10 + self._state.target_temperature = 20.1 + self._state.current_temperature = 15.1 + self._state.frost = 10.1 def set_temperature(self, temperature): diff --git a/tests/entity_registry.py b/tests/entity_registry.py index fb653cb..adbaa1e 100644 --- a/tests/entity_registry.py +++ b/tests/entity_registry.py @@ -9,5 +9,6 @@ def register(self, list): def entities(self): return self._entities + @property def first(self): return self._entities[0] \ No newline at end of file diff --git a/tests/test_climate.py b/tests/test_climate.py index c8d1767..0260b61 100644 --- a/tests/test_climate.py +++ b/tests/test_climate.py @@ -7,9 +7,30 @@ from . import mock_config -def test_entity_is_registered(): +def setup_climate_platform(): registry = EntityRegistry() config_adapter = ConfigAdapter(mock_config) climate.setup_platform(None, config_adapter, add_entities=registry.register, discovery_info=None) + return registry + + +def test_entity_is_registered(): + registry = setup_climate_platform() + + assert len(registry.entities) == 1 + + +def test_entity_returns_mock_temperature(): + registry = setup_climate_platform() + + thermostat = registry.first + + assert thermostat.current_temperature == 15.9 + + +def test_entity_returns_mock_target_temperature(): + registry = setup_climate_platform() + + thermostat = registry.first - assert len(registry.entities) == 1 \ No newline at end of file + assert thermostat.target_temperature == 20.1 \ No newline at end of file diff --git a/tests/test_thermostat_entity.py b/tests/test_thermostat_entity.py new file mode 100644 index 0000000..6f0e712 --- /dev/null +++ b/tests/test_thermostat_entity.py @@ -0,0 +1,30 @@ +import pytest +from unittest.mock import MagicMock + +from salusfy import ( ThermostatEntity, State ) + + +def test_entity_is_registered(): + mock_client = MagicMock() + mock_ha_client = MagicMock() + + state = State() + state.current_temperature = 15.3 + state.target_temperature = 33.3 + mock_client.configure_mock( + **{ + "get_state.return_value": state + } + ) + + mock_ha_client.configure_mock( + **{ + "current_temperature.return_value": 21.1 + } + ) + + target = ThermostatEntity('mock', mock_client, mock_ha_client) + + assert target.current_temperature == 21.1 + assert target.target_temperature == 33.3 + # mock_table.insert.assert_called_once() \ No newline at end of file From abb9b1ba30f3f2feeaae2d38c99d9213c7b51ff6 Mon Sep 17 00:00:00 2001 From: Matt Turner Date: Sun, 17 Dec 2023 16:00:01 +0000 Subject: [PATCH 13/55] Verify salus only called once (#3) --- tests/test_thermostat_entity.py | 59 ++++++++++++++++++++++++++++----- 1 file changed, 51 insertions(+), 8 deletions(-) diff --git a/tests/test_thermostat_entity.py b/tests/test_thermostat_entity.py index 6f0e712..ff28b3c 100644 --- a/tests/test_thermostat_entity.py +++ b/tests/test_thermostat_entity.py @@ -2,29 +2,72 @@ from unittest.mock import MagicMock from salusfy import ( ThermostatEntity, State ) +from homeassistant.components.climate.const import ( + HVAC_MODE_HEAT +) - -def test_entity_is_registered(): - mock_client = MagicMock() - mock_ha_client = MagicMock() +@pytest.fixture +def mock_client(): + mock = MagicMock() state = State() state.current_temperature = 15.3 state.target_temperature = 33.3 - mock_client.configure_mock( + mock.configure_mock( **{ "get_state.return_value": state } ) - mock_ha_client.configure_mock( + return mock + +@pytest.fixture +def mock_ha_client(): + mock = MagicMock() + + mock.configure_mock( **{ "current_temperature.return_value": 21.1 } ) + + return mock +def test_entity_returns_target_temp_from_web_client(mock_client, mock_ha_client): target = ThermostatEntity('mock', mock_client, mock_ha_client) - + + assert target.target_temperature == 33.3 + + +def test_entity_returns_target_temp_from_home_assistant_client(mock_client, mock_ha_client): + target = ThermostatEntity('mock', mock_client, mock_ha_client) + assert target.current_temperature == 21.1 + + +def test_entity_call_salus_client_only_once(mock_client, mock_ha_client): + target = ThermostatEntity('mock', mock_client, mock_ha_client) + + target.update() + target.update() + + mock_client.get_state.assert_called_once() assert target.target_temperature == 33.3 - # mock_table.insert.assert_called_once() \ No newline at end of file + + +def test_entity_delegates_set_temperature_salus_client(mock_client, mock_ha_client): + target = ThermostatEntity('mock', mock_client, mock_ha_client) + + target.set_temperature(temperature=29.9) + + mock_client.set_temperature.assert_called_once_with(29.9) + assert target.target_temperature == 29.9 + + +def test_entity_delegates_set_hvac_mode_to_salus_client(mock_client, mock_ha_client): + target = ThermostatEntity('mock', mock_client, mock_ha_client) + + target.set_hvac_mode(hvac_mode=HVAC_MODE_HEAT) + + mock_client.set_hvac_mode.assert_called_once_with(HVAC_MODE_HEAT) + assert target.hvac_mode == HVAC_MODE_HEAT \ No newline at end of file From c9d1e320fb2efcaf5c294112334e98ab069b60de Mon Sep 17 00:00:00 2001 From: Matt Turner Date: Fri, 16 Feb 2024 16:10:25 +0000 Subject: [PATCH 14/55] Update from source (#4) * Added the option to quickly reload it from dev tools * Update climate.py replaced deprecated consts * Update climate.py * Update climate.py Remove long text from log * Add asyncio * Make unit tests pass * Remove custom_components climate.py * Bump version number --------- Co-authored-by: Dan Timu Co-authored-by: aver-ua Co-authored-by: floringhimie <33951255+floringhimie@users.noreply.github.com> --- requirements.test.txt | 1 + salusfy/climate.py | 14 ++++++++++---- salusfy/thermostat_entity.py | 34 ++++++++++++++++------------------ tests/test_climate.py | 30 ++++++++++++++++++++++-------- 4 files changed, 49 insertions(+), 30 deletions(-) diff --git a/requirements.test.txt b/requirements.test.txt index 2c79089..136b030 100644 --- a/requirements.test.txt +++ b/requirements.test.txt @@ -1,3 +1,4 @@ pytest pytest-mock +pytest-asyncio pytest-homeassistant-custom-component \ No newline at end of file diff --git a/salusfy/climate.py b/salusfy/climate.py index 918f849..cee6a4a 100644 --- a/salusfy/climate.py +++ b/salusfy/climate.py @@ -21,7 +21,9 @@ from homeassistant.components.climate import PLATFORM_SCHEMA -__version__ = "0.1.0" +from homeassistant.helpers.reload import async_setup_reload_service + +__version__ = "0.3.0" _LOGGER = logging.getLogger(__name__) @@ -29,6 +31,8 @@ CONF_NAME = "name" +DOMAIN = "salusfy" +PLATFORMS = ["climate"] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -44,8 +48,10 @@ ) -def setup_platform(hass, config, add_entities, discovery_info=None): +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the E-Thermostat platform.""" + await async_setup_reload_service(hass, DOMAIN, PLATFORMS) + name = config.get(CONF_NAME) username = config.get(CONF_USERNAME) password = config.get(CONF_PASSWORD) @@ -57,7 +63,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): if (simulator): _LOGGER.info('Registering Salus simulator...') - add_entities( + async_add_entities( [ThermostatEntity(name, MockWebClient(), MockHaWebClient())] ) else: @@ -65,6 +71,6 @@ def setup_platform(hass, config, add_entities, discovery_info=None): web_client = WebClient(username, password, id) ha_client = HaWebClient(host, entity_id, access_token) - add_entities( + async_add_entities( [ThermostatEntity(name, web_client, ha_client)] ) \ No newline at end of file diff --git a/salusfy/thermostat_entity.py b/salusfy/thermostat_entity.py index d9fc715..64dad98 100644 --- a/salusfy/thermostat_entity.py +++ b/salusfy/thermostat_entity.py @@ -8,17 +8,15 @@ ) from homeassistant.components.climate.const import ( - CURRENT_HVAC_HEAT, - CURRENT_HVAC_IDLE, - HVAC_MODE_HEAT, - HVAC_MODE_OFF, - SUPPORT_PRESET_MODE, - SUPPORT_TARGET_TEMPERATURE + HVACAction, + HVACMode, + ClimateEntityFeature, + SUPPORT_PRESET_MODE ) from homeassistant.const import ( ATTR_TEMPERATURE, - TEMP_CELSIUS, + UnitOfTemperature, ) try: @@ -26,7 +24,7 @@ except ImportError: from homeassistant.components.climate import ClimateDevice as ClimateEntity -SUPPORT_FLAGS = SUPPORT_TARGET_TEMPERATURE +SUPPORT_FLAGS = ClimateEntityFeature.TARGET_TEMPERATURE class ThermostatEntity(ClimateEntity): """Representation of a Salus Thermostat device.""" @@ -73,7 +71,7 @@ def max_temp(self): @property def temperature_unit(self): """Return the unit of measurement.""" - return TEMP_CELSIUS + return UnitOfTemperature.CELSIUS @property def current_temperature(self): @@ -91,26 +89,26 @@ def hvac_mode(self): """Return hvac operation ie. heat, cool mode.""" try: climate_mode = self._state.current_operation_mode - curr_hvac_mode = HVAC_MODE_OFF + curr_hvac_mode = HVACMode.OFF if climate_mode == STATE_ON: - curr_hvac_mode = HVAC_MODE_HEAT + curr_hvac_mode = HVACMode.HEAT else: - curr_hvac_mode = HVAC_MODE_OFF + curr_hvac_mode = HVACMode.OFF except KeyError: - return HVAC_MODE_OFF + return HVACMode.OFF return curr_hvac_mode @property def hvac_modes(self): """HVAC modes.""" - return [HVAC_MODE_HEAT, HVAC_MODE_OFF] + return [HVACMode.HEAT, HVACMode.OFF] @property def hvac_action(self): """Return the current running hvac operation.""" if self._state.status == STATE_ON: - return CURRENT_HVAC_HEAT - return CURRENT_HVAC_IDLE + return HVACAction.HEATING + return HVACAction.IDLE @property @@ -142,10 +140,10 @@ def set_hvac_mode(self, hvac_mode): self._client.set_hvac_mode(hvac_mode) - if hvac_mode == HVAC_MODE_OFF: + if hvac_mode == HVACMode.OFF: self._state.current_operation_mode = STATE_OFF self._state.status = STATE_OFF - elif hvac_mode == HVAC_MODE_HEAT: + elif hvac_mode == HVACMode.HEAT: self._state.current_operation_mode = STATE_ON self._state.status = STATE_ON diff --git a/tests/test_climate.py b/tests/test_climate.py index 0260b61..b32a974 100644 --- a/tests/test_climate.py +++ b/tests/test_climate.py @@ -6,30 +6,44 @@ from . import mock_config +class MockHass: + @property + def services(self): + return self + + def has_service(self, domain, service): + return False + + def async_register(self, domain, service, admin_handler, schema): + pass -def setup_climate_platform(): +@pytest.mark.asyncio +async def setup_climate_platform(): registry = EntityRegistry() config_adapter = ConfigAdapter(mock_config) - climate.setup_platform(None, config_adapter, add_entities=registry.register, discovery_info=None) + await climate.async_setup_platform(MockHass(), config_adapter, async_add_entities=registry.register, discovery_info=None) return registry -def test_entity_is_registered(): - registry = setup_climate_platform() +@pytest.mark.asyncio +async def test_entity_is_registered(): + registry = await setup_climate_platform() assert len(registry.entities) == 1 -def test_entity_returns_mock_temperature(): - registry = setup_climate_platform() +@pytest.mark.asyncio +async def test_entity_returns_mock_temperature(): + registry = await setup_climate_platform() thermostat = registry.first assert thermostat.current_temperature == 15.9 -def test_entity_returns_mock_target_temperature(): - registry = setup_climate_platform() +@pytest.mark.asyncio +async def test_entity_returns_mock_target_temperature(): + registry = await setup_climate_platform() thermostat = registry.first From 1551167eb452db8d62e441064172ea203a726bd7 Mon Sep 17 00:00:00 2001 From: Matt Turner Date: Fri, 16 Feb 2024 22:01:18 +0000 Subject: [PATCH 15/55] Create wrapper client (#5) --- salusfy/__init__.py | 6 +- salusfy/client.py | 50 +++++++++++++ salusfy/climate.py | 45 ++++++++---- ...web_client.py => ha_temperature_client.py} | 9 ++- salusfy/simulator/__init__.py | 2 + .../temperature_client.py} | 6 +- .../web_client.py} | 17 +++-- salusfy/thermostat_entity.py | 7 +- tests/config_adapter.py | 3 + tests/entity_registry.py | 2 +- tests/mock_config.py | 1 + tests/test_client.py | 73 +++++++++++++++++++ tests/test_climate.py | 1 + tests/test_thermostat_entity.py | 39 ++-------- 14 files changed, 193 insertions(+), 68 deletions(-) create mode 100644 salusfy/client.py rename salusfy/{ha_web_client.py => ha_temperature_client.py} (80%) create mode 100644 salusfy/simulator/__init__.py rename salusfy/{mock_ha_web_client.py => simulator/temperature_client.py} (52%) rename salusfy/{mock_web_client.py => simulator/web_client.py} (80%) create mode 100644 tests/test_client.py diff --git a/salusfy/__init__.py b/salusfy/__init__.py index d0d847a..1369929 100644 --- a/salusfy/__init__.py +++ b/salusfy/__init__.py @@ -8,7 +8,7 @@ STATE_ON, STATE_OFF ) -from .mock_web_client import MockWebClient + from .thermostat_entity import ThermostatEntity -from .ha_web_client import HaWebClient -from .mock_ha_web_client import MockHaWebClient \ No newline at end of file +from .client import Client +from .ha_temperature_client import HaTemperatureClient diff --git a/salusfy/client.py b/salusfy/client.py new file mode 100644 index 0000000..49000e5 --- /dev/null +++ b/salusfy/client.py @@ -0,0 +1,50 @@ +""" +Client which wraps the web client but handles +the retrieval of current temperature by calling +a specialized client. +""" +import logging +from .web_client import WebClient +from .ha_temperature_client import HaTemperatureClient + +_LOGGER = logging.getLogger(__name__) + +class Client: + """Mocks requests to Salus web application""" + + def __init__(self, web_client : WebClient, temperature_client : HaTemperatureClient): + """Initialize the client.""" + self._state = None + self._web_client = web_client + self._temperature_client = temperature_client + + self.get_state() + + + def set_temperature(self, temperature): + """Set new target temperature.""" + + _LOGGER.info("Delegating set_temperature to web client...") + + self._web_client.set_temperature(temperature) + + + def set_hvac_mode(self, hvac_mode): + """Set HVAC mode, via URL commands.""" + + _LOGGER.info("Delegating set_hvac_mode to web client...") + + self._web_client.set_hvac_mode(hvac_mode) + + + def get_state(self): + """Retrieves the status""" + + if self._state is None: + _LOGGER.info("Delegating get_state to web client...") + self._state = self._web_client.get_state() + + _LOGGER.info("Updating current temperature from temperature client...") + self._state.current_temperature = self._temperature_client.current_temperature() + + return self._state diff --git a/salusfy/climate.py b/salusfy/climate.py index cee6a4a..de164f5 100644 --- a/salusfy/climate.py +++ b/salusfy/climate.py @@ -16,8 +16,11 @@ ) CONF_SIMULATOR = 'simulator' +CONF_ENABLE_TEMPERATURE_CLIENT = 'enable_temperature_client' -from . import ( ThermostatEntity, WebClient, MockWebClient, HaWebClient, MockHaWebClient ) +from . import ( ThermostatEntity, Client, WebClient, HaTemperatureClient ) + +from . import simulator from homeassistant.components.climate import PLATFORM_SCHEMA @@ -52,25 +55,37 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= """Set up the E-Thermostat platform.""" await async_setup_reload_service(hass, DOMAIN, PLATFORMS) + client = create_client_from(config) + name = config.get(CONF_NAME) + await async_add_entities( + [ThermostatEntity(name, client)] + ) + + +def create_client_from(config): username = config.get(CONF_USERNAME) password = config.get(CONF_PASSWORD) id = config.get(CONF_ID) - simulator = config.get(CONF_SIMULATOR) + enable_simulator = config.get(CONF_SIMULATOR) + enable_temperature_client = config.get(CONF_ENABLE_TEMPERATURE_CLIENT) entity_id = config.get(CONF_ENTITY_ID) host = config.get(CONF_HOST) access_token = config.get(CONF_ACCESS_TOKEN) - if (simulator): - _LOGGER.info('Registering Salus simulator...') - async_add_entities( - [ThermostatEntity(name, MockWebClient(), MockHaWebClient())] - ) - else: - _LOGGER.info('Registering Salus Thermostat climate entity...') - web_client = WebClient(username, password, id) - ha_client = HaWebClient(host, entity_id, access_token) - - async_add_entities( - [ThermostatEntity(name, web_client, ha_client)] - ) \ No newline at end of file + if enable_simulator: + _LOGGER.info('Registering Salus Thermostat client simulator...') + + return Client(simulator.WebClient(), simulator.TemperatureClient()) + + web_client = WebClient(username, password, id) + + if not enable_temperature_client: + _LOGGER.info('Registering Salus Thermostat client...') + + return web_client + + _LOGGER.info('Registering Salus Thermostat client with Temperature client...') + + ha_client = HaTemperatureClient(host, entity_id, access_token) + return Client(web_client, ha_client) \ No newline at end of file diff --git a/salusfy/ha_web_client.py b/salusfy/ha_temperature_client.py similarity index 80% rename from salusfy/ha_web_client.py rename to salusfy/ha_temperature_client.py index 577187f..6ee8ce3 100644 --- a/salusfy/ha_web_client.py +++ b/salusfy/ha_temperature_client.py @@ -1,6 +1,11 @@ from requests import get -class HaWebClient: +""" +Retrieves the current temperature from +another entity from the Home Assistant API +""" + +class HaTemperatureClient: def __init__(self, host, entity_id, access_token): self._entity_id = entity_id self._host = host @@ -8,7 +13,7 @@ def __init__(self, host, entity_id, access_token): def current_temperature(self): - """Gets the current temperature from """ + """Gets the current temperature from HA""" url = F"http://{self._host}:8123/api/states/{self._entity_id}" diff --git a/salusfy/simulator/__init__.py b/salusfy/simulator/__init__.py new file mode 100644 index 0000000..983a8d7 --- /dev/null +++ b/salusfy/simulator/__init__.py @@ -0,0 +1,2 @@ +from .temperature_client import TemperatureClient +from .web_client import WebClient \ No newline at end of file diff --git a/salusfy/mock_ha_web_client.py b/salusfy/simulator/temperature_client.py similarity index 52% rename from salusfy/mock_ha_web_client.py rename to salusfy/simulator/temperature_client.py index 91a6834..2e70faa 100644 --- a/salusfy/mock_ha_web_client.py +++ b/salusfy/simulator/temperature_client.py @@ -1,5 +1,7 @@ - -class MockHaWebClient: +""" +Adds support for simulating the Salus Thermostats. +""" +class TemperatureClient: def __init__(self): pass diff --git a/salusfy/mock_web_client.py b/salusfy/simulator/web_client.py similarity index 80% rename from salusfy/mock_web_client.py rename to salusfy/simulator/web_client.py index 967d8a2..2281178 100644 --- a/salusfy/mock_web_client.py +++ b/salusfy/simulator/web_client.py @@ -1,12 +1,14 @@ """ -Adds support for the Salus Thermostat units. +Adds support for simulating the Salus Thermostats. """ import logging -from . import ( +from homeassistant.components.climate.const import ( + HVACMode, +) + +from .. import ( State, - HVAC_MODE_HEAT, - HVAC_MODE_OFF, STATE_ON, STATE_OFF ) @@ -14,7 +16,8 @@ _LOGGER = logging.getLogger(__name__) -class MockWebClient: + +class WebClient: """Mocks requests to Salus web application""" def __init__(self): @@ -38,9 +41,9 @@ def set_hvac_mode(self, hvac_mode): _LOGGER.info("Setting the HVAC mode to %s...", hvac_mode) - if hvac_mode == HVAC_MODE_OFF: + if hvac_mode == HVACMode.OFF: self._state.current_operation_mode = STATE_OFF - elif hvac_mode == HVAC_MODE_HEAT: + elif hvac_mode == HVACMode.HEAT: self._state.current_operation_mode = STATE_ON diff --git a/salusfy/thermostat_entity.py b/salusfy/thermostat_entity.py index 64dad98..3be9921 100644 --- a/salusfy/thermostat_entity.py +++ b/salusfy/thermostat_entity.py @@ -29,11 +29,10 @@ class ThermostatEntity(ClimateEntity): """Representation of a Salus Thermostat device.""" - def __init__(self, name, client, ha_client): + def __init__(self, name, client): """Initialize the thermostat.""" self._name = name self._client = client - self._ha_client = ha_client self._state = None self.update() @@ -150,6 +149,4 @@ def set_hvac_mode(self, hvac_mode): def update(self): """Get the latest state data.""" - if self._state is None: - self._state = self._client.get_state() - self._state.current_temperature = self._ha_client.current_temperature() \ No newline at end of file + self._state = self._client.get_state() \ No newline at end of file diff --git a/tests/config_adapter.py b/tests/config_adapter.py index 55ea6b2..f3f40bd 100644 --- a/tests/config_adapter.py +++ b/tests/config_adapter.py @@ -19,6 +19,9 @@ def get(self, key): if (key == 'simulator'): return self._config.SIMULATOR + if (key == 'enable_temperature_client'): + return self._config.ENABLE_TEMPERATURE_CLIENT + if (key == 'host'): return self._config.HOST diff --git a/tests/entity_registry.py b/tests/entity_registry.py index adbaa1e..43d153d 100644 --- a/tests/entity_registry.py +++ b/tests/entity_registry.py @@ -2,7 +2,7 @@ class EntityRegistry: def __init__(self): self._entities = [] - def register(self, list): + async def register(self, list): self._entities.extend(list) @property diff --git a/tests/mock_config.py b/tests/mock_config.py index 3d4e823..5af154f 100644 --- a/tests/mock_config.py +++ b/tests/mock_config.py @@ -4,4 +4,5 @@ ENTITY_ID = "sensor.everything_presence_one_temperature" ACCESS_TOKEN = "some-secret" SIMULATOR = True +ENABLE_TEMPERATURE_CLIENT = True HOST = "192.168.0.99" \ No newline at end of file diff --git a/tests/test_client.py b/tests/test_client.py new file mode 100644 index 0000000..c044a17 --- /dev/null +++ b/tests/test_client.py @@ -0,0 +1,73 @@ +import pytest +from unittest.mock import MagicMock + +from salusfy import ( Client, State ) +from homeassistant.components.climate.const import ( + HVAC_MODE_HEAT +) + +@pytest.fixture +def mock_client(): + mock = MagicMock() + + state = State() + state.current_temperature = 15.3 + state.target_temperature = 33.3 + mock.configure_mock( + **{ + "get_state.return_value": state + } + ) + + return mock + + +@pytest.fixture +def mock_ha_client(): + mock = MagicMock() + + mock.configure_mock( + **{ + "current_temperature.return_value": 21.1 + } + ) + + return mock + + +def test_entity_returns_target_temp_from_web_client(mock_client, mock_ha_client): + target = Client(mock_client, mock_ha_client) + + assert target.get_state().target_temperature == 33.3 + + +def test_entity_returns_target_temp_from_home_assistant_client(mock_client, mock_ha_client): + target = Client(mock_client, mock_ha_client) + + assert target.get_state().current_temperature == 21.1 + + +def test_entity_call_salus_client_only_once(mock_client, mock_ha_client): + target = Client(mock_client, mock_ha_client) + + target.get_state() + target.get_state() + + mock_client.get_state.assert_called_once() + assert target.get_state().target_temperature == 33.3 + + +def test_entity_delegates_set_temperature_salus_client(mock_client, mock_ha_client): + target = Client(mock_client, mock_ha_client) + + target.set_temperature(temperature=29.9) + + mock_client.set_temperature.assert_called_once_with(29.9) + + +def test_entity_delegates_set_hvac_mode_to_salus_client(mock_client, mock_ha_client): + target = Client(mock_client, mock_ha_client) + + target.set_hvac_mode(hvac_mode=HVAC_MODE_HEAT) + + mock_client.set_hvac_mode.assert_called_once_with(HVAC_MODE_HEAT) \ No newline at end of file diff --git a/tests/test_climate.py b/tests/test_climate.py index b32a974..ed4e754 100644 --- a/tests/test_climate.py +++ b/tests/test_climate.py @@ -17,6 +17,7 @@ def has_service(self, domain, service): def async_register(self, domain, service, admin_handler, schema): pass + @pytest.mark.asyncio async def setup_climate_platform(): registry = EntityRegistry() diff --git a/tests/test_thermostat_entity.py b/tests/test_thermostat_entity.py index ff28b3c..7f41380 100644 --- a/tests/test_thermostat_entity.py +++ b/tests/test_thermostat_entity.py @@ -21,42 +21,15 @@ def mock_client(): return mock -@pytest.fixture -def mock_ha_client(): - mock = MagicMock() - - mock.configure_mock( - **{ - "current_temperature.return_value": 21.1 - } - ) - - return mock - -def test_entity_returns_target_temp_from_web_client(mock_client, mock_ha_client): - target = ThermostatEntity('mock', mock_client, mock_ha_client) - - assert target.target_temperature == 33.3 - - -def test_entity_returns_target_temp_from_home_assistant_client(mock_client, mock_ha_client): - target = ThermostatEntity('mock', mock_client, mock_ha_client) - - assert target.current_temperature == 21.1 - - -def test_entity_call_salus_client_only_once(mock_client, mock_ha_client): - target = ThermostatEntity('mock', mock_client, mock_ha_client) - target.update() - target.update() +def test_entity_returns_target_temp_from_web_client(mock_client): + target = ThermostatEntity('mock', mock_client) - mock_client.get_state.assert_called_once() assert target.target_temperature == 33.3 -def test_entity_delegates_set_temperature_salus_client(mock_client, mock_ha_client): - target = ThermostatEntity('mock', mock_client, mock_ha_client) +def test_entity_delegates_set_temperature_web_client(mock_client): + target = ThermostatEntity('mock', mock_client) target.set_temperature(temperature=29.9) @@ -64,8 +37,8 @@ def test_entity_delegates_set_temperature_salus_client(mock_client, mock_ha_clie assert target.target_temperature == 29.9 -def test_entity_delegates_set_hvac_mode_to_salus_client(mock_client, mock_ha_client): - target = ThermostatEntity('mock', mock_client, mock_ha_client) +def test_entity_delegates_set_hvac_mode_to_web_client(mock_client): + target = ThermostatEntity('mock', mock_client) target.set_hvac_mode(hvac_mode=HVAC_MODE_HEAT) From fc9837c635a33a14e611e3999535ce50f1847e3f Mon Sep 17 00:00:00 2001 From: Matt Turner Date: Thu, 14 Dec 2023 22:08:16 +0000 Subject: [PATCH 16/55] Refactor to extract web client --- .gitignore | 2 + README.md | 10 +- config.sample.py | 8 ++ custom_components/salusfy/climate.py | 26 +--- custom_components/salusfy/state.py | 8 ++ .../salusfy/thermostat_entity.py | 134 ++++++++++++++++++ custom_components/salusfy/web_client.py | 128 +++++++++++++++++ run.py | 17 +++ 8 files changed, 307 insertions(+), 26 deletions(-) create mode 100644 .gitignore create mode 100644 config.sample.py create mode 100644 custom_components/salusfy/state.py create mode 100644 custom_components/salusfy/thermostat_entity.py create mode 100644 custom_components/salusfy/web_client.py create mode 100644 run.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..94f973f --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +config.py +*.pyc \ No newline at end of file diff --git a/README.md b/README.md index 19cb21f..afbfd75 100644 --- a/README.md +++ b/README.md @@ -2,13 +2,15 @@ Custom Components for Home-Assistant (http://www.home-assistant.io) # Salus Thermostat Climate Component -My device is RT301i, it is working with it500 thermostat, the ideea is simple if you have a Salus Thermostat and you are able to login to salus-it500.com and controll it from this page, this custom component should work. -Component to interface with the salus-it500.com. +My device is RT301i, it is working with it500 thermostat, the ideea is simple if you have a Salus Thermostat and you are able to login to salus-it500.com and control it from this page, this custom component should work. + +## Component to interface with the salus-it500.com. It reads the Current Temperature, Set Temperature, Current HVAC Mode, Current Relay Mode. Keep in mind this is my first custom component and this is also the first version of this Salusfy so it can have bugs. Sorry for that. **** This is not an official integration. + ### Installation * If not exist, in config/custom_components/ create a directory called salusfy * Copy all files in salusfy to your config/custom_components/salusfy/ directory. @@ -32,7 +34,7 @@ climate: ### Getting the DEVICEID -1. Loggin to https://salus-it500.com with email and password used in the mobile app(in my case RT301i) +1. Loggin to https://salus-it500.com with email and password used in the mobile app (in my case RT301i) 2. Click on the device 3. In the next page you will be able to see the device ID in the page URL 4. Copy the device ID from the URL @@ -40,4 +42,4 @@ climate: ### Known issues -salus-it500.com server is bloking the IP of the host, in our case the HA external IP. This can be fixed with router restart in case of PPOE connection or you can try to send a mail to salus support... +Due to how chatty the HA integration is, the salus-it500.com server may start blocking your public IP address (and rightly so). This will prevent the gateway and mobile client from connecting. This implementation aims to resolve this by suppressing requests in many circumstances. The effect of this is that the current temperature value will be out of date but the main control features (target temperature, set status etc) will still work. \ No newline at end of file diff --git a/config.sample.py b/config.sample.py new file mode 100644 index 0000000..7181c86 --- /dev/null +++ b/config.sample.py @@ -0,0 +1,8 @@ +# this file is used by the run.py test script +# it is not required by the custom component + +# copy this file to config.py and replace the values + +USERNAME = "replace" +PASSWORD = "replace" +DEVICE_ID = "replace" \ No newline at end of file diff --git a/custom_components/salusfy/climate.py b/custom_components/salusfy/climate.py index 80aa095..8392cc2 100644 --- a/custom_components/salusfy/climate.py +++ b/custom_components/salusfy/climate.py @@ -1,12 +1,7 @@ """ Adds support for the Salus Thermostat units. """ -import datetime -import time import logging -import re -import requests -import json import homeassistant.helpers.config_validation as cv import voluptuous as vol @@ -16,23 +11,16 @@ ClimateEntityFeature, ) from homeassistant.const import ( - ATTR_TEMPERATURE, CONF_PASSWORD, CONF_USERNAME, CONF_ID, UnitOfTemperature, ) -try: - from homeassistant.components.climate import ( - ClimateEntity, - PLATFORM_SCHEMA, - ) -except ImportError: - from homeassistant.components.climate import ( - ClimateDevice as ClimateEntity, - PLATFORM_SCHEMA, - ) +from custom_components.salusfy.thermostat_entity import ThermostatEntity +from custom_components.salusfy.web_client import WebClient + +from homeassistant.components.climate import PLATFORM_SCHEMA from homeassistant.helpers.reload import async_setup_reload_service @@ -42,14 +30,8 @@ _LOGGER = logging.getLogger(__name__) -URL_LOGIN = "https://salus-it500.com/public/login.php" -URL_GET_TOKEN = "https://salus-it500.com/public/control.php" -URL_GET_DATA = "https://salus-it500.com/public/ajax_device_values.php" -URL_SET_DATA = "https://salus-it500.com/includes/set.php" - DEFAULT_NAME = "Salus Thermostat" - CONF_NAME = "name" # Values from web interface diff --git a/custom_components/salusfy/state.py b/custom_components/salusfy/state.py new file mode 100644 index 0000000..f484717 --- /dev/null +++ b/custom_components/salusfy/state.py @@ -0,0 +1,8 @@ +class State: + """The state of the thermostat.""" + def __init__(self): + self.current_temperature = None + self.target_temperature = None + self.frost = None + self.status = None + self.current_operation_mode = None \ No newline at end of file diff --git a/custom_components/salusfy/thermostat_entity.py b/custom_components/salusfy/thermostat_entity.py new file mode 100644 index 0000000..db77cfa --- /dev/null +++ b/custom_components/salusfy/thermostat_entity.py @@ -0,0 +1,134 @@ +import logging + +from custom_components.salusfy.web_client import ( + WebClient, + STATE_ON +) + +from homeassistant.components.climate.const import ( + CURRENT_HVAC_HEAT, + CURRENT_HVAC_IDLE, + HVAC_MODE_HEAT, + HVAC_MODE_OFF, + SUPPORT_PRESET_MODE, + SUPPORT_TARGET_TEMPERATURE +) + +from homeassistant.const import ( + ATTR_TEMPERATURE, + TEMP_CELSIUS, +) + +try: + from homeassistant.components.climate import ClimateEntity +except ImportError: + from homeassistant.components.climate import ClimateDevice as ClimateEntity + +SUPPORT_FLAGS = SUPPORT_TARGET_TEMPERATURE + +class ThermostatEntity(ClimateEntity): + """Representation of a Salus Thermostat device.""" + + def __init__(self, name, client): + """Initialize the thermostat.""" + self._name = name + self._client = client + self._state = None + + self.update() + + @property + def supported_features(self): + """Return the list of supported features.""" + return SUPPORT_FLAGS + + @property + def name(self): + """Return the name of the thermostat.""" + return self._name + + @property + def unique_id(self) -> str: + """Return the unique ID for this thermostat.""" + return "_".join([self._name, "climate"]) + + @property + def should_poll(self): + """Return if polling is required.""" + return True + + @property + def min_temp(self): + """Return the minimum temperature.""" + return self._client.MIN_TEMP + + @property + def max_temp(self): + """Return the maximum temperature.""" + return self._client.MAX_TEMP + + @property + def temperature_unit(self): + """Return the unit of measurement.""" + return TEMP_CELSIUS + + @property + def current_temperature(self): + """Return the current temperature.""" + return self._state.current_temperature + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + return self._state.target_temperature + + + @property + def hvac_mode(self): + """Return hvac operation ie. heat, cool mode.""" + try: + climate_mode = self._state.current_operation_mode + curr_hvac_mode = HVAC_MODE_OFF + if climate_mode == STATE_ON: + curr_hvac_mode = HVAC_MODE_HEAT + else: + curr_hvac_mode = HVAC_MODE_OFF + except KeyError: + return HVAC_MODE_OFF + return curr_hvac_mode + + @property + def hvac_modes(self): + """HVAC modes.""" + return [HVAC_MODE_HEAT, HVAC_MODE_OFF] + + @property + def hvac_action(self): + """Return the current running hvac operation.""" + if self._state.status == STATE_ON: + return CURRENT_HVAC_HEAT + return CURRENT_HVAC_IDLE + + + @property + def preset_mode(self): + """Return the current preset mode, e.g., home, away, temp.""" + return self._state.status + + @property + def preset_modes(self): + """Return a list of available preset modes.""" + return SUPPORT_PRESET_MODE + + + def set_temperature(self, **kwargs): + """Set new target temperature.""" + temperature = kwargs.get(ATTR_TEMPERATURE) + if temperature is None: + return + self._client.set_temperature(temperature) + + + def update(self): + """Get the latest state data.""" + self._state = self._client.get_state() \ No newline at end of file diff --git a/custom_components/salusfy/web_client.py b/custom_components/salusfy/web_client.py new file mode 100644 index 0000000..8de79fd --- /dev/null +++ b/custom_components/salusfy/web_client.py @@ -0,0 +1,128 @@ +""" +Adds support for the Salus Thermostat units. +""" +import time +import logging +import re +import requests +import json + +from custom_components.salusfy.state import State + +HVAC_MODE_HEAT = "heat" +HVAC_MODE_OFF = "off" + +STATE_ON = "ON" +STATE_OFF = "OFF" + +_LOGGER = logging.getLogger(__name__) + +URL_LOGIN = "https://salus-it500.com/public/login.php" +URL_GET_TOKEN = "https://salus-it500.com/public/control.php" +URL_GET_DATA = "https://salus-it500.com/public/ajax_device_values.php" +URL_SET_DATA = "https://salus-it500.com/includes/set.php" + +# Values from web interface +MIN_TEMP = 5 +MAX_TEMP = 34.5 + +class WebClient: + """Adapter around Salus IT500 web application.""" + + def __init__(self, username, password, id): + """Initialize the client.""" + self._username = username + self._password = password + self._id = id + self._token = None + + self._session = requests.Session() + + + def set_temperature(self, temperature): + """Set new target temperature, via URL commands.""" + payload = {"token": self._token, "devId": self._id, "tempUnit": "0", "current_tempZ1_set": "1", "current_tempZ1": temperature} + headers = {"content-type": "application/x-www-form-urlencoded"} + try: + self._session.post(URL_SET_DATA, data=payload, headers=headers) + _LOGGER.info("Salusfy set_temperature OK") + except: + _LOGGER.error("Error Setting the temperature.") + + + def set_hvac_mode(self, hvac_mode): + """Set HVAC mode, via URL commands.""" + + headers = {"content-type": "application/x-www-form-urlencoded"} + if hvac_mode == HVAC_MODE_OFF: + payload = {"token": self._token, "devId": self._id, "auto": "1", "auto_setZ1": "1"} + try: + self._session.post(URL_SET_DATA, data=payload, headers=headers) + except: + _LOGGER.error("Error Setting HVAC mode OFF.") + elif hvac_mode == HVAC_MODE_HEAT: + payload = {"token": self._token, "devId": self._id, "auto": "0", "auto_setZ1": "1"} + try: + self._session.post(URL_SET_DATA, data=payload, headers=headers) + except: + _LOGGER.error("Error Setting HVAC mode.") + + _LOGGER.info("Setting the HVAC mode.") + + + def get_token(self): + """Get the Session Token of the Thermostat.""" + payload = {"IDemail": self._username, "password": self._password, "login": "Login", "keep_logged_in": "1"} + headers = {"content-type": "application/x-www-form-urlencoded"} + + try: + self._session.post(URL_LOGIN, data=payload, headers=headers) + params = {"devId": self._id} + getTkoken = self._session.get(URL_GET_TOKEN,params=params) + result = re.search('', getTkoken.text) + _LOGGER.info("Salusfy get_token OK") + self._token = result.group(1) + except: + _LOGGER.error("Error getting the session token.") + + + def get_state(self): + if self._token is None: + self.get_token() + + params = {"devId": self._id, "token": self._token, "&_": str(int(round(time.time() * 1000)))} + try: + r = self._session.get(url = URL_GET_DATA, params = params) + if not r: + _LOGGER.error("Could not get data from Salus.") + return None + except: + _LOGGER.error("Error Getting the data from Web. Please check the connection to salus-it500.com manually.") + return None + + try: + data = json.loads(r.text) + _LOGGER.info("Salusfy get_data output " + r.text) + + state = State() + state.target_temperature = float(data["CH1currentSetPoint"]) + state.current_temperature = float(data["CH1currentRoomTemp"]) + state.frost = float(data["frost"]) + + status = data['CH1heatOnOffStatus'] + if status == "1": + state.status = STATE_ON + else: + state.status = STATE_OFF + + mode = data['CH1heatOnOff'] + if mode == "1": + state.current_operation_mode = STATE_OFF + else: + state.current_operation_mode = STATE_ON + + return state + except: + self.get_token() + return self.get_state() + diff --git a/run.py b/run.py new file mode 100644 index 0000000..1f6804d --- /dev/null +++ b/run.py @@ -0,0 +1,17 @@ +# To run this script to test the component: +# 1 Copy config.sample.py to config.py +# 2 Replace the username/password/deviceid (don't worry, this file will be ignored by git) +# 3 Run with `python run.py` + +from custom_components.salusfy.thermostat_entity import ThermostatEntity +from custom_components.salusfy.web_client import WebClient + +import config + +client = WebClient(config.USERNAME, config.PASSWORD, config.DEVICE_ID) +thermostat = ThermostatEntity("thermostat", client) + +print("Current: " + str(thermostat.current_temperature)) +print("Target: " + str(thermostat.target_temperature)) +print("HVAC Action: " + thermostat.hvac_action) +print("HVAC Mode: " + thermostat.hvac_mode) \ No newline at end of file From acc4a127323f05178bbce079a3f9cbb7c0cf2051 Mon Sep 17 00:00:00 2001 From: Matt Turner Date: Thu, 14 Dec 2023 22:32:55 +0000 Subject: [PATCH 17/55] Add mock web client --- custom_components/salusfy/climate.py | 217 ++---------------- custom_components/salusfy/manifest.json | 8 +- custom_components/salusfy/mock_web_client.py | 45 ++++ .../salusfy/thermostat_entity.py | 1 - run.py | 4 + 5 files changed, 67 insertions(+), 208 deletions(-) create mode 100644 custom_components/salusfy/mock_web_client.py diff --git a/custom_components/salusfy/climate.py b/custom_components/salusfy/climate.py index 8392cc2..7fa4a8d 100644 --- a/custom_components/salusfy/climate.py +++ b/custom_components/salusfy/climate.py @@ -19,6 +19,7 @@ from custom_components.salusfy.thermostat_entity import ThermostatEntity from custom_components.salusfy.web_client import WebClient +from custom_components.salusfy.mock_web_client import MockWebClient from homeassistant.components.climate import PLATFORM_SCHEMA @@ -34,15 +35,7 @@ CONF_NAME = "name" -# Values from web interface -MIN_TEMP = 5 -MAX_TEMP = 34.5 - -SUPPORT_FLAGS = ClimateEntityFeature.TARGET_TEMPERATURE - -DOMAIN = "salusfy" -PLATFORMS = ["climate"] - +CONF_SIMULATOR = 'simulator' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( @@ -51,6 +44,7 @@ vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string, vol.Required(CONF_ID): cv.string, + vol.Optional(CONF_SIMULATOR, default=False): cv.boolean } ) @@ -63,6 +57,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= username = config.get(CONF_USERNAME) password = config.get(CONF_PASSWORD) id = config.get(CONF_ID) + simulator = config.get(CONF_SIMULATOR) # add_entities( # [SalusThermostat(name, username, password, id)] @@ -70,199 +65,13 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= [SalusThermostat(name, username, password, id)] ) + if (simulator): + add_entities( + [ThermostatEntity(name, MockWebClient())] + ) + else: + web_client = WebClient(username, password, id) -class SalusThermostat(ClimateEntity): - """Representation of a Salus Thermostat device.""" - - def __init__(self, name, username, password, id): - """Initialize the thermostat.""" - self._name = name - self._username = username - self._password = password - self._id = id - self._current_temperature = None - self._target_temperature = None - self._frost = None - self._status = None - self._current_operation_mode = None - self._token = None - - self._session = requests.Session() - - - self.update() - - @property - def supported_features(self): - """Return the list of supported features.""" - return SUPPORT_FLAGS - - @property - def name(self): - """Return the name of the thermostat.""" - return self._name - - @property - def unique_id(self) -> str: - """Return the unique ID for this thermostat.""" - return "_".join([self._name, "climate"]) - - @property - def should_poll(self): - """Return if polling is required.""" - return True - - @property - def min_temp(self): - """Return the minimum temperature.""" - return MIN_TEMP - - @property - def max_temp(self): - """Return the maximum temperature.""" - return MAX_TEMP - - @property - def temperature_unit(self): - """Return the unit of measurement.""" - return UnitOfTemperature.CELSIUS - - @property - def current_temperature(self): - """Return the current temperature.""" - return self._current_temperature - - @property - def target_temperature(self): - """Return the temperature we try to reach.""" - return self._target_temperature - - - @property - def hvac_mode(self): - """Return hvac operation ie. heat, cool mode.""" - try: - climate_mode = self._current_operation_mode - curr_hvac_mode = HVACMode.OFF - if climate_mode == "ON": - curr_hvac_mode = HVACMode.HEAT - else: - curr_hvac_mode = HVACMode.OFF - except KeyError: - return HVACMode.OFF - return curr_hvac_mode - - @property - def hvac_modes(self): - """HVAC modes.""" - return [HVACMode.HEAT, HVACMode.OFF] - - @property - def hvac_action(self): - """Return the current running hvac operation.""" - if self._status == "ON": - return HVACAction.HEATING - return HVACAction.IDLE - - - @property - def preset_mode(self): - """Return the current preset mode, e.g., home, away, temp.""" - return self._status - - @property - def preset_modes(self): - """Return a list of available preset modes.""" - return SUPPORT_PRESET - - - def set_temperature(self, **kwargs): - """Set new target temperature.""" - temperature = kwargs.get(ATTR_TEMPERATURE) - if temperature is None: - return - self._set_temperature(temperature) - - def _set_temperature(self, temperature): - """Set new target temperature, via URL commands.""" - payload = {"token": self._token, "devId": self._id, "tempUnit": "0", "current_tempZ1_set": "1", "current_tempZ1": temperature} - headers = {"content-type": "application/x-www-form-urlencoded"} - try: - if self._session.post(URL_SET_DATA, data=payload, headers=headers): - self._target_temperature = temperature - # self.schedule_update_ha_state(force_refresh=True) - _LOGGER.info("Salusfy set_temperature OK") - except: - _LOGGER.error("Error Setting the temperature.") - - def set_hvac_mode(self, hvac_mode): - """Set HVAC mode, via URL commands.""" - - headers = {"content-type": "application/x-www-form-urlencoded"} - if hvac_mode == HVACMode.OFF: - payload = {"token": self._token, "devId": self._id, "auto": "1", "auto_setZ1": "1"} - try: - if self._session.post(URL_SET_DATA, data=payload, headers=headers): - self._current_operation_mode = "OFF" - except: - _LOGGER.error("Error Setting HVAC mode OFF.") - elif hvac_mode == HVACMode.HEAT: - payload = {"token": self._token, "devId": self._id, "auto": "0", "auto_setZ1": "1"} - try: - if self._session.post(URL_SET_DATA, data=payload, headers=headers): - self._current_operation_mode = "ON" - except: - _LOGGER.error("Error Setting HVAC mode.") - _LOGGER.info("Setting the HVAC mode.") - - def get_token(self): - """Get the Session Token of the Thermostat.""" - payload = {"IDemail": self._username, "password": self._password, "login": "Login", "keep_logged_in": "1"} - headers = {"content-type": "application/x-www-form-urlencoded"} - - try: - self._session.post(URL_LOGIN, data=payload, headers=headers) - params = {"devId": self._id} - getTkoken = self._session.get(URL_GET_TOKEN,params=params) - result = re.search('', getTkoken.text) - _LOGGER.info("Salusfy get_token OK") - self._token = result.group(1) - except: - _LOGGER.error("Error Geting the Session Token.") - - def _get_data(self): - if self._token is None: - self.get_token() - params = {"devId": self._id, "token": self._token, "&_": str(int(round(time.time() * 1000)))} - try: - r = self._session.get(url = URL_GET_DATA, params = params) - try: - if r: - data = json.loads(r.text) - _LOGGER.info("Salusfy get_data output OK") - self._target_temperature = float(data["CH1currentSetPoint"]) - self._current_temperature = float(data["CH1currentRoomTemp"]) - self._frost = float(data["frost"]) - - status = data['CH1heatOnOffStatus'] - if status == "1": - self._status = "ON" - else: - self._status = "OFF" - mode = data['CH1heatOnOff'] - if mode == "1": - self._current_operation_mode = "OFF" - else: - self._current_operation_mode = "ON" - else: - _LOGGER.error("Could not get data from Salus.") - except: - self.get_token() - self._get_data() - except: - _LOGGER.error("Error Geting the data from Web. Please check the connection to salus-it500.com manually.") - - def update(self): - """Get the latest data.""" - self._get_data() - + add_entities( + [ThermostatEntity(name, web_client)] + ) diff --git a/custom_components/salusfy/manifest.json b/custom_components/salusfy/manifest.json index 84b6d13..7ade128 100644 --- a/custom_components/salusfy/manifest.json +++ b/custom_components/salusfy/manifest.json @@ -1,11 +1,13 @@ { "domain": "salusfy", "name": "Salus thermostat", - "version": "0.0.1", + "version": "0.1.0", "documentation": "https://github.com/floringhimie/salusfy", "issue_tracker": "https://github.com/floringhimie/salusfy/issues", "requirements": [], "dependencies": [], - "codeowners": ["@floringhimie"], + "codeowners": [ + "@floringhimie" + ], "iot_class": "cloud_polling" -} +} \ No newline at end of file diff --git a/custom_components/salusfy/mock_web_client.py b/custom_components/salusfy/mock_web_client.py new file mode 100644 index 0000000..14992d5 --- /dev/null +++ b/custom_components/salusfy/mock_web_client.py @@ -0,0 +1,45 @@ +""" +Adds support for the Salus Thermostat units. +""" +import logging + +from custom_components.salusfy.state import State +from custom_components.salusfy.web_client import ( + HVAC_MODE_HEAT, + HVAC_MODE_OFF, + STATE_ON, + STATE_OFF +) + + +_LOGGER = logging.getLogger(__name__) + +class MockWebClient: + """Mocks requests to Salus web application""" + + def __init__(self): + """Initialize the client.""" + self._state = State() + self._state.target_temperature = 20 + self._state.current_temperature = 15 + self._state.frost = 10 + + + def set_temperature(self, temperature): + """Set new target temperature.""" + self._state.target_temperature = temperature + _LOGGER.info("Salusfy set_temperature OK") + + + def set_hvac_mode(self, hvac_mode): + """Set HVAC mode, via URL commands.""" + if hvac_mode == HVAC_MODE_OFF: + self._state.current_operation_mode = STATE_OFF + elif hvac_mode == HVAC_MODE_HEAT: + self._state.current_operation_mode = STATE_ON + _LOGGER.info("Setting the HVAC mode.") + + + def get_state(self): + """Retrieves the mock status""" + return self._state diff --git a/custom_components/salusfy/thermostat_entity.py b/custom_components/salusfy/thermostat_entity.py index db77cfa..911f0dd 100644 --- a/custom_components/salusfy/thermostat_entity.py +++ b/custom_components/salusfy/thermostat_entity.py @@ -1,7 +1,6 @@ import logging from custom_components.salusfy.web_client import ( - WebClient, STATE_ON ) diff --git a/run.py b/run.py index 1f6804d..b8a6b46 100644 --- a/run.py +++ b/run.py @@ -5,10 +5,14 @@ from custom_components.salusfy.thermostat_entity import ThermostatEntity from custom_components.salusfy.web_client import WebClient +from custom_components.salusfy.mock_web_client import MockWebClient import config +# choose either client +# client = MockWebClient() client = WebClient(config.USERNAME, config.PASSWORD, config.DEVICE_ID) + thermostat = ThermostatEntity("thermostat", client) print("Current: " + str(thermostat.current_temperature)) From 3eedc193f679df14c5e3b7be3931e096dbeb76e6 Mon Sep 17 00:00:00 2001 From: Matt Turner Date: Fri, 15 Dec 2023 07:35:49 +0000 Subject: [PATCH 18/55] Fix import order --- custom_components/salusfy/__init__.py | 11 +++++ custom_components/salusfy/climate.py | 15 +++---- custom_components/salusfy/mock_web_client.py | 4 +- install.sh | 4 ++ run.py | 46 +++++++++++++++++--- 5 files changed, 61 insertions(+), 19 deletions(-) create mode 100644 install.sh diff --git a/custom_components/salusfy/__init__.py b/custom_components/salusfy/__init__.py index cced37c..e7b4cfe 100644 --- a/custom_components/salusfy/__init__.py +++ b/custom_components/salusfy/__init__.py @@ -1 +1,12 @@ """The Salus component.""" + +from .state import State +from .web_client import ( + WebClient, + HVAC_MODE_HEAT, + HVAC_MODE_OFF, + STATE_ON, + STATE_OFF +) +from .mock_web_client import MockWebClient +from .thermostat_entity import ThermostatEntity \ No newline at end of file diff --git a/custom_components/salusfy/climate.py b/custom_components/salusfy/climate.py index 7fa4a8d..d32d1ef 100644 --- a/custom_components/salusfy/climate.py +++ b/custom_components/salusfy/climate.py @@ -17,9 +17,9 @@ UnitOfTemperature, ) -from custom_components.salusfy.thermostat_entity import ThermostatEntity -from custom_components.salusfy.web_client import WebClient -from custom_components.salusfy.mock_web_client import MockWebClient +CONF_SIMULATOR = 'simulator' + +from . import ( ThermostatEntity, WebClient, MockWebClient ) from homeassistant.components.climate import PLATFORM_SCHEMA @@ -35,7 +35,6 @@ CONF_NAME = "name" -CONF_SIMULATOR = 'simulator' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( @@ -59,17 +58,13 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= id = config.get(CONF_ID) simulator = config.get(CONF_SIMULATOR) - # add_entities( - # [SalusThermostat(name, username, password, id)] - async_add_entities( - [SalusThermostat(name, username, password, id)] - ) - if (simulator): + _LOGGER.info('Registering Salus simulator...') add_entities( [ThermostatEntity(name, MockWebClient())] ) else: + _LOGGER.info('Registering Salus Thermostat climate entity...') web_client = WebClient(username, password, id) add_entities( diff --git a/custom_components/salusfy/mock_web_client.py b/custom_components/salusfy/mock_web_client.py index 14992d5..a60acd9 100644 --- a/custom_components/salusfy/mock_web_client.py +++ b/custom_components/salusfy/mock_web_client.py @@ -3,8 +3,8 @@ """ import logging -from custom_components.salusfy.state import State -from custom_components.salusfy.web_client import ( +from . import ( + State, HVAC_MODE_HEAT, HVAC_MODE_OFF, STATE_ON, diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..c1cf0e5 --- /dev/null +++ b/install.sh @@ -0,0 +1,4 @@ + +# expects the repository to be cloned within the homeassistant directory + +cp ./custom_components/salusfy/*.* ../custom_components/salusfy \ No newline at end of file diff --git a/run.py b/run.py index b8a6b46..9d04a5d 100644 --- a/run.py +++ b/run.py @@ -3,17 +3,49 @@ # 2 Replace the username/password/deviceid (don't worry, this file will be ignored by git) # 3 Run with `python run.py` -from custom_components.salusfy.thermostat_entity import ThermostatEntity -from custom_components.salusfy.web_client import WebClient -from custom_components.salusfy.mock_web_client import MockWebClient +from custom_components.salusfy import climate import config -# choose either client -# client = MockWebClient() -client = WebClient(config.USERNAME, config.PASSWORD, config.DEVICE_ID) +class ConfigAdapter: + def __init__(self, config): + self._config = config -thermostat = ThermostatEntity("thermostat", client) + + def get(self, key): + if (key == 'name'): + return 'Simulator' + + if (key == 'id'): + return self._config.DEVICE_ID + + if (key == 'username'): + return self._config.USERNAME + + if (key == 'password'): + return self._config.PASSWORD + + if (key == 'simulator'): + return self._config.SIMULATOR + + +class EntityRegistry: + def __init__(self): + self._entities = [] + + def register(self, list): + self._entities.extend(list) + + def first(self): + return self._entities[0] + + +registry = EntityRegistry() +config_adapter = ConfigAdapter(config) + +climate.setup_platform(None, config_adapter, add_entities=registry.register, discovery_info=None) + +thermostat = registry.first() print("Current: " + str(thermostat.current_temperature)) print("Target: " + str(thermostat.target_temperature)) From 5daf74e953ea250702e37dac541c0822e80bb08b Mon Sep 17 00:00:00 2001 From: Matt Turner Date: Fri, 15 Dec 2023 08:01:23 +0000 Subject: [PATCH 19/55] Fix min/max temp --- custom_components/salusfy/thermostat_entity.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/custom_components/salusfy/thermostat_entity.py b/custom_components/salusfy/thermostat_entity.py index 911f0dd..e78ea0a 100644 --- a/custom_components/salusfy/thermostat_entity.py +++ b/custom_components/salusfy/thermostat_entity.py @@ -1,7 +1,9 @@ import logging from custom_components.salusfy.web_client import ( - STATE_ON + STATE_ON, + MAX_TEMP, + MIN_TEMP ) from homeassistant.components.climate.const import ( @@ -59,12 +61,12 @@ def should_poll(self): @property def min_temp(self): """Return the minimum temperature.""" - return self._client.MIN_TEMP + return MIN_TEMP @property def max_temp(self): """Return the maximum temperature.""" - return self._client.MAX_TEMP + return MAX_TEMP @property def temperature_unit(self): From 8324fe20fb5ab19eaa0893c6d57e750544480245 Mon Sep 17 00:00:00 2001 From: Matt Turner Date: Fri, 15 Dec 2023 20:51:51 +0000 Subject: [PATCH 20/55] Create HA connection --- README.md | 2 ++ custom_components/salusfy/__init__.py | 3 ++- custom_components/salusfy/climate.py | 24 +++++++++++-------- custom_components/salusfy/ha_web_client.py | 21 ++++++++++++++++ .../salusfy/thermostat_entity.py | 21 +++++++++++++--- run.py | 14 +++++++++++ 6 files changed, 71 insertions(+), 14 deletions(-) create mode 100644 custom_components/salusfy/ha_web_client.py diff --git a/README.md b/README.md index afbfd75..53eb9c5 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,8 @@ climate: username: "EMAIL" password: "PASSWORD" id: "DEVICEID" + entity_id: "sensor.temperature" + access_token: "ha_long_lived_token" ``` ![image](https://user-images.githubusercontent.com/33951255/140300295-4915a18f-f5d4-4957-b513-59d7736cc52a.png) ![image](https://user-images.githubusercontent.com/33951255/140303472-fd38b9e4-5c33-408f-afef-25547c39551c.png) diff --git a/custom_components/salusfy/__init__.py b/custom_components/salusfy/__init__.py index e7b4cfe..db3557a 100644 --- a/custom_components/salusfy/__init__.py +++ b/custom_components/salusfy/__init__.py @@ -9,4 +9,5 @@ STATE_OFF ) from .mock_web_client import MockWebClient -from .thermostat_entity import ThermostatEntity \ No newline at end of file +from .thermostat_entity import ThermostatEntity +from .ha_web_client import HaWebClient \ No newline at end of file diff --git a/custom_components/salusfy/climate.py b/custom_components/salusfy/climate.py index d32d1ef..daadfb4 100644 --- a/custom_components/salusfy/climate.py +++ b/custom_components/salusfy/climate.py @@ -14,20 +14,18 @@ CONF_PASSWORD, CONF_USERNAME, CONF_ID, - UnitOfTemperature, + CONF_ENTITY_ID, + CONF_ACCESS_TOKEN, + CONF_HOST ) CONF_SIMULATOR = 'simulator' -from . import ( ThermostatEntity, WebClient, MockWebClient ) +from . import ( ThermostatEntity, WebClient, MockWebClient, HaWebClient ) from homeassistant.components.climate import PLATFORM_SCHEMA - -from homeassistant.helpers.reload import async_setup_reload_service - -__version__ = "0.0.3" - +__version__ = "0.1.0" _LOGGER = logging.getLogger(__name__) @@ -43,7 +41,8 @@ vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string, vol.Required(CONF_ID): cv.string, - vol.Optional(CONF_SIMULATOR, default=False): cv.boolean + vol.Optional(CONF_SIMULATOR, default=False): cv.boolean, + vol.Optional(CONF_HOST, default='localhost'): cv.string } ) @@ -57,16 +56,21 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= password = config.get(CONF_PASSWORD) id = config.get(CONF_ID) simulator = config.get(CONF_SIMULATOR) + entity_id = config.get(CONF_ENTITY_ID) + host = config.get(CONF_HOST) + access_token = config.get(CONF_ACCESS_TOKEN) + + ha_client = HaWebClient(host, entity_id, access_token) if (simulator): _LOGGER.info('Registering Salus simulator...') add_entities( - [ThermostatEntity(name, MockWebClient())] + [ThermostatEntity(name, MockWebClient(), ha_client)] ) else: _LOGGER.info('Registering Salus Thermostat climate entity...') web_client = WebClient(username, password, id) add_entities( - [ThermostatEntity(name, web_client)] + [ThermostatEntity(name, web_client, ha_client)] ) diff --git a/custom_components/salusfy/ha_web_client.py b/custom_components/salusfy/ha_web_client.py new file mode 100644 index 0000000..7b576c4 --- /dev/null +++ b/custom_components/salusfy/ha_web_client.py @@ -0,0 +1,21 @@ +from requests import get + +class HaWebClient: + def __init__(self, host, entity_id, access_token): + self._entity_id = entity_id + self._host = host + self._access_token = access_token + + + def current_temperature(self): + """Gets the current temperature from """ + + url = F"http://{self._host}:8123/api/states/{self._entity_id}" + + headers = { + "Authorization": F"Bearer {self._access_token}", + "Content-Type": "application/json", + } + + response = get(url, headers=headers) + return float(response.json()['state']) \ No newline at end of file diff --git a/custom_components/salusfy/thermostat_entity.py b/custom_components/salusfy/thermostat_entity.py index e78ea0a..4b4b2db 100644 --- a/custom_components/salusfy/thermostat_entity.py +++ b/custom_components/salusfy/thermostat_entity.py @@ -2,6 +2,7 @@ from custom_components.salusfy.web_client import ( STATE_ON, + STATE_OFF, MAX_TEMP, MIN_TEMP ) @@ -30,10 +31,11 @@ class ThermostatEntity(ClimateEntity): """Representation of a Salus Thermostat device.""" - def __init__(self, name, client): + def __init__(self, name, client, ha_client): """Initialize the thermostat.""" self._name = name self._client = client + self._ha_client = ha_client self._state = None self.update() @@ -76,7 +78,7 @@ def temperature_unit(self): @property def current_temperature(self): """Return the current temperature.""" - return self._state.current_temperature + return self._ha_client.current_temperature() @property def target_temperature(self): @@ -128,8 +130,21 @@ def set_temperature(self, **kwargs): if temperature is None: return self._client.set_temperature(temperature) + self._state.target_temperature = temperature + def set_hvac_mode(self, hvac_mode): + """Set HVAC mode, via URL commands.""" + if hvac_mode == HVAC_MODE_OFF: + self._state.current_operation_mode = STATE_OFF + self._state.status = STATE_OFF + elif hvac_mode == HVAC_MODE_HEAT: + self._state.current_operation_mode = STATE_ON + self._state.status = STATE_ON + self._client.set_hvac_mode(hvac_mode) + + def update(self): """Get the latest state data.""" - self._state = self._client.get_state() \ No newline at end of file + if self._state is None: + self._state = self._client.get_state() \ No newline at end of file diff --git a/run.py b/run.py index 9d04a5d..fe899bb 100644 --- a/run.py +++ b/run.py @@ -28,6 +28,15 @@ def get(self, key): if (key == 'simulator'): return self._config.SIMULATOR + if (key == 'host'): + return self._config.HOST + + if (key == 'entity_id'): + return self._config.ENTITY_ID + + if (key == 'access_token'): + return self._config.ACCESS_TOKEN + class EntityRegistry: def __init__(self): @@ -47,6 +56,11 @@ def first(self): thermostat = registry.first() +thermostat.update() +thermostat.update() + +thermostat.set_hvac_mode('off') + print("Current: " + str(thermostat.current_temperature)) print("Target: " + str(thermostat.target_temperature)) print("HVAC Action: " + thermostat.hvac_action) From 552cff5dc8126ae87dbef19ca85316fb6da8dbbf Mon Sep 17 00:00:00 2001 From: Matt Turner Date: Fri, 15 Dec 2023 20:59:38 +0000 Subject: [PATCH 21/55] Add config values --- custom_components/salusfy/climate.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/custom_components/salusfy/climate.py b/custom_components/salusfy/climate.py index daadfb4..6a4d40a 100644 --- a/custom_components/salusfy/climate.py +++ b/custom_components/salusfy/climate.py @@ -42,6 +42,8 @@ vol.Required(CONF_PASSWORD): cv.string, vol.Required(CONF_ID): cv.string, vol.Optional(CONF_SIMULATOR, default=False): cv.boolean, + vol.Required(CONF_ENTITY_ID): cv.string, + vol.Required(CONF_ACCESS_TOKEN): cv.string, vol.Optional(CONF_HOST, default='localhost'): cv.string } ) From 0ae985a230767ef0a9d8006912495c0a4e369f06 Mon Sep 17 00:00:00 2001 From: Matt Turner Date: Fri, 15 Dec 2023 21:39:50 +0000 Subject: [PATCH 22/55] Improved handling when starting up --- custom_components/salusfy/ha_web_client.py | 12 +++++++++++- custom_components/salusfy/thermostat_entity.py | 5 +++-- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/custom_components/salusfy/ha_web_client.py b/custom_components/salusfy/ha_web_client.py index 7b576c4..577187f 100644 --- a/custom_components/salusfy/ha_web_client.py +++ b/custom_components/salusfy/ha_web_client.py @@ -18,4 +18,14 @@ def current_temperature(self): } response = get(url, headers=headers) - return float(response.json()['state']) \ No newline at end of file + + body = response.json() + + if 'state' not in body: + return None + + state = body['state'] + if state == 'unavailable': + return None + + return float(state) \ No newline at end of file diff --git a/custom_components/salusfy/thermostat_entity.py b/custom_components/salusfy/thermostat_entity.py index 4b4b2db..f86b9dc 100644 --- a/custom_components/salusfy/thermostat_entity.py +++ b/custom_components/salusfy/thermostat_entity.py @@ -78,7 +78,7 @@ def temperature_unit(self): @property def current_temperature(self): """Return the current temperature.""" - return self._ha_client.current_temperature() + return self._state.current_temperature @property def target_temperature(self): @@ -147,4 +147,5 @@ def set_hvac_mode(self, hvac_mode): def update(self): """Get the latest state data.""" if self._state is None: - self._state = self._client.get_state() \ No newline at end of file + self._state = self._client.get_state() + self._state.current_temperature = self._ha_client.current_temperature() \ No newline at end of file From e50bdbba9c4b8fcabfe009e24feb9e1366fba844 Mon Sep 17 00:00:00 2001 From: Matt Turner Date: Fri, 15 Dec 2023 21:45:00 +0000 Subject: [PATCH 23/55] Improve readme --- README.md | 11 ++++++++--- custom_components/salusfy/manifest.json | 2 +- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 53eb9c5..09cd426 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ climate: - platform: salusfy username: "EMAIL" password: "PASSWORD" - id: "DEVICEID" + id: "DEVICE_ID" entity_id: "sensor.temperature" access_token: "ha_long_lived_token" ``` @@ -35,7 +35,7 @@ climate: ![image](https://user-images.githubusercontent.com/33951255/140303472-fd38b9e4-5c33-408f-afef-25547c39551c.png) -### Getting the DEVICEID +### Getting the DEVICE_ID 1. Loggin to https://salus-it500.com with email and password used in the mobile app (in my case RT301i) 2. Click on the device 3. In the next page you will be able to see the device ID in the page URL @@ -44,4 +44,9 @@ climate: ### Known issues -Due to how chatty the HA integration is, the salus-it500.com server may start blocking your public IP address (and rightly so). This will prevent the gateway and mobile client from connecting. This implementation aims to resolve this by suppressing requests in many circumstances. The effect of this is that the current temperature value will be out of date but the main control features (target temperature, set status etc) will still work. \ No newline at end of file +Due to how chatty the HA integration is, the salus-it500.com server may start blocking your public IP address (and rightly so). This will prevent the gateway and mobile client from connecting. This implementation aims to resolve this by: + +* suppressing requests to Salus in many circumstances +* querying another entity for current temperature + +The effect of this is that the target temperature/status values may be out of date if it has been outside of HA, but the main control features (target temperature, set status etc) will still work. \ No newline at end of file diff --git a/custom_components/salusfy/manifest.json b/custom_components/salusfy/manifest.json index 7ade128..d483edf 100644 --- a/custom_components/salusfy/manifest.json +++ b/custom_components/salusfy/manifest.json @@ -1,6 +1,6 @@ { "domain": "salusfy", - "name": "Salus thermostat", + "name": "Salus Thermostat", "version": "0.1.0", "documentation": "https://github.com/floringhimie/salusfy", "issue_tracker": "https://github.com/floringhimie/salusfy/issues", From 43f36d21ad01706c07dc1f2595b3da034ad472a2 Mon Sep 17 00:00:00 2001 From: Matt Turner Date: Sat, 16 Dec 2023 16:52:11 +0000 Subject: [PATCH 24/55] Reuse cached token for 10 mins --- custom_components/salusfy/mock_web_client.py | 8 +- .../salusfy/thermostat_entity.py | 8 +- custom_components/salusfy/web_client.py | 120 +++++++++++------- run.py | 11 +- 4 files changed, 99 insertions(+), 48 deletions(-) diff --git a/custom_components/salusfy/mock_web_client.py b/custom_components/salusfy/mock_web_client.py index a60acd9..aa5f0d3 100644 --- a/custom_components/salusfy/mock_web_client.py +++ b/custom_components/salusfy/mock_web_client.py @@ -27,17 +27,21 @@ def __init__(self): def set_temperature(self, temperature): """Set new target temperature.""" + + _LOGGER.info("Setting temperature to %.1f...", temperature) + self._state.target_temperature = temperature - _LOGGER.info("Salusfy set_temperature OK") def set_hvac_mode(self, hvac_mode): """Set HVAC mode, via URL commands.""" + + _LOGGER.info("Setting the HVAC mode to %s...", hvac_mode) + if hvac_mode == HVAC_MODE_OFF: self._state.current_operation_mode = STATE_OFF elif hvac_mode == HVAC_MODE_HEAT: self._state.current_operation_mode = STATE_ON - _LOGGER.info("Setting the HVAC mode.") def get_state(self): diff --git a/custom_components/salusfy/thermostat_entity.py b/custom_components/salusfy/thermostat_entity.py index f86b9dc..3dc9780 100644 --- a/custom_components/salusfy/thermostat_entity.py +++ b/custom_components/salusfy/thermostat_entity.py @@ -126,22 +126,28 @@ def preset_modes(self): def set_temperature(self, **kwargs): """Set new target temperature.""" + temperature = kwargs.get(ATTR_TEMPERATURE) + if temperature is None: return + self._client.set_temperature(temperature) + self._state.target_temperature = temperature def set_hvac_mode(self, hvac_mode): """Set HVAC mode, via URL commands.""" + + self._client.set_hvac_mode(hvac_mode) + if hvac_mode == HVAC_MODE_OFF: self._state.current_operation_mode = STATE_OFF self._state.status = STATE_OFF elif hvac_mode == HVAC_MODE_HEAT: self._state.current_operation_mode = STATE_ON self._state.status = STATE_ON - self._client.set_hvac_mode(hvac_mode) def update(self): diff --git a/custom_components/salusfy/web_client.py b/custom_components/salusfy/web_client.py index 8de79fd..de1ba1d 100644 --- a/custom_components/salusfy/web_client.py +++ b/custom_components/salusfy/web_client.py @@ -25,6 +25,7 @@ # Values from web interface MIN_TEMP = 5 MAX_TEMP = 34.5 +MAX_TOKEN_AGE_SECONDS = 60 * 10 class WebClient: """Adapter around Salus IT500 web application.""" @@ -35,17 +36,24 @@ def __init__(self, username, password, id): self._password = password self._id = id self._token = None + self._tokenRetrievedAt = None self._session = requests.Session() def set_temperature(self, temperature): """Set new target temperature, via URL commands.""" - payload = {"token": self._token, "devId": self._id, "tempUnit": "0", "current_tempZ1_set": "1", "current_tempZ1": temperature} - headers = {"content-type": "application/x-www-form-urlencoded"} + + _LOGGER.info("Setting the temperature to %.1f...", temperature) + + token = self.obtain_token() + + payload = {"token": token, "devId": self._id, "tempUnit": "0", "current_tempZ1_set": "1", "current_tempZ1": temperature} + headers = {"Content-Type": "application/x-www-form-urlencoded"} + try: self._session.post(URL_SET_DATA, data=payload, headers=headers) - _LOGGER.info("Salusfy set_temperature OK") + _LOGGER.info("Salusfy set_temperature: OK") except: _LOGGER.error("Error Setting the temperature.") @@ -53,44 +61,72 @@ def set_temperature(self, temperature): def set_hvac_mode(self, hvac_mode): """Set HVAC mode, via URL commands.""" - headers = {"content-type": "application/x-www-form-urlencoded"} + _LOGGER.info("Setting the HVAC mode to %s...", hvac_mode) + + headers = {"Content-Type": "application/x-www-form-urlencoded"} + + auto = "1" if hvac_mode == HVAC_MODE_OFF: - payload = {"token": self._token, "devId": self._id, "auto": "1", "auto_setZ1": "1"} - try: - self._session.post(URL_SET_DATA, data=payload, headers=headers) - except: - _LOGGER.error("Error Setting HVAC mode OFF.") + auto = "1" elif hvac_mode == HVAC_MODE_HEAT: - payload = {"token": self._token, "devId": self._id, "auto": "0", "auto_setZ1": "1"} - try: - self._session.post(URL_SET_DATA, data=payload, headers=headers) - except: - _LOGGER.error("Error Setting HVAC mode.") + auto = "0" + + token = self.obtain_token() + + payload = {"token": token, "devId": self._id, "auto": auto, "auto_setZ1": "1"} + try: + self._session.post(URL_SET_DATA, data=payload, headers=headers) + except: + _LOGGER.error("Error Setting HVAC mode to %s", hvac_mode) + + + def obtain_token(self): + """Gets the existing session token of the thermostat or retrieves a new one if expired.""" - _LOGGER.info("Setting the HVAC mode.") + if self._token is None: + _LOGGER.info("Retrieving token for the first time this session...") + self.get_token() + return self._token + + if self._tokenRetrievedAt > time.time() - MAX_TOKEN_AGE_SECONDS: + _LOGGER.info("Using cached token...") + return self._token + + _LOGGER.info("Token has expired, getting new one...") + self.get_token() + return self._token def get_token(self): """Get the Session Token of the Thermostat.""" + + _LOGGER.info("Getting token from Salus...") + payload = {"IDemail": self._username, "password": self._password, "login": "Login", "keep_logged_in": "1"} - headers = {"content-type": "application/x-www-form-urlencoded"} + headers = {"Content-Type": "application/x-www-form-urlencoded"} try: self._session.post(URL_LOGIN, data=payload, headers=headers) params = {"devId": self._id} - getTkoken = self._session.get(URL_GET_TOKEN,params=params) + getTkoken = self._session.get(URL_GET_TOKEN, params=params) result = re.search('', getTkoken.text) _LOGGER.info("Salusfy get_token OK") self._token = result.group(1) + self._tokenRetrievedAt = time.time() except: + self._token = None + self._tokenRetrievedAt = None _LOGGER.error("Error getting the session token.") def get_state(self): - if self._token is None: - self.get_token() + """Retrieve the current state from the Salus gateway""" + + _LOGGER.info("Retrieving current state from Salus Gateway...") + + token = self.obtain_token() - params = {"devId": self._id, "token": self._token, "&_": str(int(round(time.time() * 1000)))} + params = {"devId": self._id, "token": token, "&_": str(int(round(time.time() * 1000)))} try: r = self._session.get(url = URL_GET_DATA, params = params) if not r: @@ -100,29 +136,25 @@ def get_state(self): _LOGGER.error("Error Getting the data from Web. Please check the connection to salus-it500.com manually.") return None - try: - data = json.loads(r.text) - _LOGGER.info("Salusfy get_data output " + r.text) + data = json.loads(r.text) + _LOGGER.info("Salusfy get_data output " + r.text) - state = State() - state.target_temperature = float(data["CH1currentSetPoint"]) - state.current_temperature = float(data["CH1currentRoomTemp"]) - state.frost = float(data["frost"]) - - status = data['CH1heatOnOffStatus'] - if status == "1": - state.status = STATE_ON - else: - state.status = STATE_OFF - - mode = data['CH1heatOnOff'] - if mode == "1": - state.current_operation_mode = STATE_OFF - else: - state.current_operation_mode = STATE_ON - - return state - except: - self.get_token() - return self.get_state() + state = State() + state.target_temperature = float(data["CH1currentSetPoint"]) + state.current_temperature = float(data["CH1currentRoomTemp"]) + state.frost = float(data["frost"]) + + status = data['CH1heatOnOffStatus'] + if status == "1": + state.status = STATE_ON + else: + state.status = STATE_OFF + + mode = data['CH1heatOnOff'] + if mode == "1": + state.current_operation_mode = STATE_OFF + else: + state.current_operation_mode = STATE_ON + + return state diff --git a/run.py b/run.py index fe899bb..08dc881 100644 --- a/run.py +++ b/run.py @@ -5,8 +5,16 @@ from custom_components.salusfy import climate +from homeassistant.components.climate.const import ( + HVAC_MODE_HEAT, + HVAC_MODE_OFF, +) + import config +import logging +logging.basicConfig(format='%(levelname)s: %(message)s', level=logging.DEBUG) + class ConfigAdapter: def __init__(self, config): self._config = config @@ -59,7 +67,8 @@ def first(self): thermostat.update() thermostat.update() -thermostat.set_hvac_mode('off') +thermostat.set_hvac_mode(HVAC_MODE_HEAT) +thermostat.set_temperature(temperature=9.8) print("Current: " + str(thermostat.current_temperature)) print("Target: " + str(thermostat.target_temperature)) From 23f0ef6de40dc62580344663a2bf5b595f8991ae Mon Sep 17 00:00:00 2001 From: Matt Turner Date: Sat, 16 Dec 2023 16:57:35 +0000 Subject: [PATCH 25/55] Improve output of install script --- install.sh | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/install.sh b/install.sh index c1cf0e5..12a0454 100644 --- a/install.sh +++ b/install.sh @@ -1,4 +1,8 @@ # expects the repository to be cloned within the homeassistant directory -cp ./custom_components/salusfy/*.* ../custom_components/salusfy \ No newline at end of file +echo "Copying all files from the cloned repo to the Home Assistant custom_components directory..." + +cp --verbose ./custom_components/salusfy/*.* ../custom_components/salusfy + +echo "Restart Home Assistant to apply the changes" \ No newline at end of file From ada46e7a8a7418fe3589e6187dae6bd9da9bf660 Mon Sep 17 00:00:00 2001 From: Matt Turner Date: Sat, 16 Dec 2023 21:24:23 +0000 Subject: [PATCH 26/55] Add unit tests (#1) --- .github/workflows/ci.yml | 37 +++++++++++++++ Dockerfile | 12 +++++ install.sh | 2 +- requirements.test.txt | 2 + requirements.txt | 1 + run.py | 46 ++----------------- .../salusfy => salusfy}/__init__.py | 3 +- .../salusfy => salusfy}/climate.py | 8 ++-- .../salusfy => salusfy}/ha_web_client.py | 0 .../salusfy => salusfy}/manifest.json | 0 salusfy/mock_ha_web_client.py | 7 +++ .../salusfy => salusfy}/mock_web_client.py | 0 .../salusfy => salusfy}/state.py | 0 .../salusfy => salusfy}/thermostat_entity.py | 2 +- .../salusfy => salusfy}/web_client.py | 2 +- test.ps1 | 5 ++ tests/__init__.py | 0 tests/config_adapter.py | 29 ++++++++++++ tests/entity_registry.py | 13 ++++++ tests/mock_config.py | 7 +++ tests/test_climate.py | 15 ++++++ 21 files changed, 139 insertions(+), 52 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 Dockerfile create mode 100644 requirements.test.txt create mode 100644 requirements.txt rename {custom_components/salusfy => salusfy}/__init__.py (74%) rename {custom_components/salusfy => salusfy}/climate.py (92%) rename {custom_components/salusfy => salusfy}/ha_web_client.py (100%) rename {custom_components/salusfy => salusfy}/manifest.json (100%) create mode 100644 salusfy/mock_ha_web_client.py rename {custom_components/salusfy => salusfy}/mock_web_client.py (100%) rename {custom_components/salusfy => salusfy}/state.py (100%) rename {custom_components/salusfy => salusfy}/thermostat_entity.py (98%) rename {custom_components/salusfy => salusfy}/web_client.py (99%) create mode 100644 test.ps1 create mode 100644 tests/__init__.py create mode 100644 tests/config_adapter.py create mode 100644 tests/entity_registry.py create mode 100644 tests/mock_config.py create mode 100644 tests/test_climate.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3dae740 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,37 @@ +name: CI + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +permissions: + contents: read + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + - name: Set up Python 3.10 + uses: actions/setup-python@v3 + with: + python-version: "3.10" + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install flake8 pytest + if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + if [ -f requirements.test.txt ]; then pip install -r requirements.test.txt; fi + - name: Lint with flake8 + run: | + # stop the build if there are Python syntax errors or undefined names + flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics + # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide + flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + - name: Test with pytest + run: | + pytest \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..2baad14 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,12 @@ +FROM python:3.9.10 +ENV PIP_DISABLE_ROOT_WARNING=1 +RUN python -m pip install --upgrade pip +COPY requirements.test.txt requirements.test.txt +RUN pip install -r requirements.test.txt +COPY requirements.txt requirements.txt +RUN pip install -r requirements.txt +VOLUME /app +WORKDIR /app +ENV NO_COLOR=yes_please +ENV LANG=C +CMD ["python", "-m", "pytest"] \ No newline at end of file diff --git a/install.sh b/install.sh index 12a0454..03f73b8 100644 --- a/install.sh +++ b/install.sh @@ -3,6 +3,6 @@ echo "Copying all files from the cloned repo to the Home Assistant custom_components directory..." -cp --verbose ./custom_components/salusfy/*.* ../custom_components/salusfy +cp --verbose ./salusfy/*.* ../custom_components/salusfy echo "Restart Home Assistant to apply the changes" \ No newline at end of file diff --git a/requirements.test.txt b/requirements.test.txt new file mode 100644 index 0000000..1709c74 --- /dev/null +++ b/requirements.test.txt @@ -0,0 +1,2 @@ +pytest +pytest-homeassistant-custom-component \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..f229360 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +requests diff --git a/run.py b/run.py index 08dc881..90e7116 100644 --- a/run.py +++ b/run.py @@ -3,7 +3,9 @@ # 2 Replace the username/password/deviceid (don't worry, this file will be ignored by git) # 3 Run with `python run.py` -from custom_components.salusfy import climate +from salusfy import climate +from tests.config_adapter import ConfigAdapter +from tests.entity_registry import EntityRegistry from homeassistant.components.climate.const import ( HVAC_MODE_HEAT, @@ -15,48 +17,6 @@ import logging logging.basicConfig(format='%(levelname)s: %(message)s', level=logging.DEBUG) -class ConfigAdapter: - def __init__(self, config): - self._config = config - - - def get(self, key): - if (key == 'name'): - return 'Simulator' - - if (key == 'id'): - return self._config.DEVICE_ID - - if (key == 'username'): - return self._config.USERNAME - - if (key == 'password'): - return self._config.PASSWORD - - if (key == 'simulator'): - return self._config.SIMULATOR - - if (key == 'host'): - return self._config.HOST - - if (key == 'entity_id'): - return self._config.ENTITY_ID - - if (key == 'access_token'): - return self._config.ACCESS_TOKEN - - -class EntityRegistry: - def __init__(self): - self._entities = [] - - def register(self, list): - self._entities.extend(list) - - def first(self): - return self._entities[0] - - registry = EntityRegistry() config_adapter = ConfigAdapter(config) diff --git a/custom_components/salusfy/__init__.py b/salusfy/__init__.py similarity index 74% rename from custom_components/salusfy/__init__.py rename to salusfy/__init__.py index db3557a..d0d847a 100644 --- a/custom_components/salusfy/__init__.py +++ b/salusfy/__init__.py @@ -10,4 +10,5 @@ ) from .mock_web_client import MockWebClient from .thermostat_entity import ThermostatEntity -from .ha_web_client import HaWebClient \ No newline at end of file +from .ha_web_client import HaWebClient +from .mock_ha_web_client import MockHaWebClient \ No newline at end of file diff --git a/custom_components/salusfy/climate.py b/salusfy/climate.py similarity index 92% rename from custom_components/salusfy/climate.py rename to salusfy/climate.py index 6a4d40a..4b7653c 100644 --- a/custom_components/salusfy/climate.py +++ b/salusfy/climate.py @@ -21,7 +21,7 @@ CONF_SIMULATOR = 'simulator' -from . import ( ThermostatEntity, WebClient, MockWebClient, HaWebClient ) +from . import ( ThermostatEntity, WebClient, MockWebClient, HaWebClient, MockHaWebClient ) from homeassistant.components.climate import PLATFORM_SCHEMA @@ -34,7 +34,6 @@ CONF_NAME = "name" - PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, @@ -62,16 +61,15 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= host = config.get(CONF_HOST) access_token = config.get(CONF_ACCESS_TOKEN) - ha_client = HaWebClient(host, entity_id, access_token) - if (simulator): _LOGGER.info('Registering Salus simulator...') add_entities( - [ThermostatEntity(name, MockWebClient(), ha_client)] + [ThermostatEntity(name, MockWebClient(), MockHaWebClient())] ) else: _LOGGER.info('Registering Salus Thermostat climate entity...') web_client = WebClient(username, password, id) + ha_client = HaWebClient(host, entity_id, access_token) add_entities( [ThermostatEntity(name, web_client, ha_client)] diff --git a/custom_components/salusfy/ha_web_client.py b/salusfy/ha_web_client.py similarity index 100% rename from custom_components/salusfy/ha_web_client.py rename to salusfy/ha_web_client.py diff --git a/custom_components/salusfy/manifest.json b/salusfy/manifest.json similarity index 100% rename from custom_components/salusfy/manifest.json rename to salusfy/manifest.json diff --git a/salusfy/mock_ha_web_client.py b/salusfy/mock_ha_web_client.py new file mode 100644 index 0000000..91a6834 --- /dev/null +++ b/salusfy/mock_ha_web_client.py @@ -0,0 +1,7 @@ + +class MockHaWebClient: + def __init__(self): + pass + + def current_temperature(self): + return 15.9 \ No newline at end of file diff --git a/custom_components/salusfy/mock_web_client.py b/salusfy/mock_web_client.py similarity index 100% rename from custom_components/salusfy/mock_web_client.py rename to salusfy/mock_web_client.py diff --git a/custom_components/salusfy/state.py b/salusfy/state.py similarity index 100% rename from custom_components/salusfy/state.py rename to salusfy/state.py diff --git a/custom_components/salusfy/thermostat_entity.py b/salusfy/thermostat_entity.py similarity index 98% rename from custom_components/salusfy/thermostat_entity.py rename to salusfy/thermostat_entity.py index 3dc9780..d9fc715 100644 --- a/custom_components/salusfy/thermostat_entity.py +++ b/salusfy/thermostat_entity.py @@ -1,6 +1,6 @@ import logging -from custom_components.salusfy.web_client import ( +from .web_client import ( STATE_ON, STATE_OFF, MAX_TEMP, diff --git a/custom_components/salusfy/web_client.py b/salusfy/web_client.py similarity index 99% rename from custom_components/salusfy/web_client.py rename to salusfy/web_client.py index de1ba1d..e0b0742 100644 --- a/custom_components/salusfy/web_client.py +++ b/salusfy/web_client.py @@ -7,7 +7,7 @@ import requests import json -from custom_components.salusfy.state import State +from .state import State HVAC_MODE_HEAT = "heat" HVAC_MODE_OFF = "off" diff --git a/test.ps1 b/test.ps1 new file mode 100644 index 0000000..0d77805 --- /dev/null +++ b/test.ps1 @@ -0,0 +1,5 @@ +# use this to run test suite on windows hosts + +docker build -t salusfy . + +docker run -it -v .:/app salusfy \ No newline at end of file diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/config_adapter.py b/tests/config_adapter.py new file mode 100644 index 0000000..55ea6b2 --- /dev/null +++ b/tests/config_adapter.py @@ -0,0 +1,29 @@ +class ConfigAdapter: + def __init__(self, config): + self._config = config + + + def get(self, key): + if (key == 'name'): + return 'Simulator' + + if (key == 'id'): + return self._config.DEVICE_ID + + if (key == 'username'): + return self._config.USERNAME + + if (key == 'password'): + return self._config.PASSWORD + + if (key == 'simulator'): + return self._config.SIMULATOR + + if (key == 'host'): + return self._config.HOST + + if (key == 'entity_id'): + return self._config.ENTITY_ID + + if (key == 'access_token'): + return self._config.ACCESS_TOKEN \ No newline at end of file diff --git a/tests/entity_registry.py b/tests/entity_registry.py new file mode 100644 index 0000000..fb653cb --- /dev/null +++ b/tests/entity_registry.py @@ -0,0 +1,13 @@ +class EntityRegistry: + def __init__(self): + self._entities = [] + + def register(self, list): + self._entities.extend(list) + + @property + def entities(self): + return self._entities + + def first(self): + return self._entities[0] \ No newline at end of file diff --git a/tests/mock_config.py b/tests/mock_config.py new file mode 100644 index 0000000..3d4e823 --- /dev/null +++ b/tests/mock_config.py @@ -0,0 +1,7 @@ +USERNAME = "john@smith.com" +PASSWORD = "12345" +DEVICE_ID = "999999" +ENTITY_ID = "sensor.everything_presence_one_temperature" +ACCESS_TOKEN = "some-secret" +SIMULATOR = True +HOST = "192.168.0.99" \ No newline at end of file diff --git a/tests/test_climate.py b/tests/test_climate.py new file mode 100644 index 0000000..c8d1767 --- /dev/null +++ b/tests/test_climate.py @@ -0,0 +1,15 @@ +import pytest + +from salusfy import climate +from .config_adapter import ConfigAdapter +from .entity_registry import EntityRegistry + +from . import mock_config + + +def test_entity_is_registered(): + registry = EntityRegistry() + config_adapter = ConfigAdapter(mock_config) + climate.setup_platform(None, config_adapter, add_entities=registry.register, discovery_info=None) + + assert len(registry.entities) == 1 \ No newline at end of file From 796b12df91adf7428de5ef81410a653c60085154 Mon Sep 17 00:00:00 2001 From: Matt Turner Date: Sun, 17 Dec 2023 07:50:52 +0000 Subject: [PATCH 27/55] Add thermostat entity unit tests (#2) --- .gitignore | 3 ++- requirements.test.txt | 1 + run.py | 2 +- salusfy/mock_web_client.py | 6 +++--- tests/entity_registry.py | 1 + tests/test_climate.py | 25 +++++++++++++++++++++++-- tests/test_thermostat_entity.py | 30 ++++++++++++++++++++++++++++++ 7 files changed, 61 insertions(+), 7 deletions(-) create mode 100644 tests/test_thermostat_entity.py diff --git a/.gitignore b/.gitignore index 94f973f..9da8b8c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ config.py -*.pyc \ No newline at end of file +*.pyc +__pycache__ \ No newline at end of file diff --git a/requirements.test.txt b/requirements.test.txt index 1709c74..2c79089 100644 --- a/requirements.test.txt +++ b/requirements.test.txt @@ -1,2 +1,3 @@ pytest +pytest-mock pytest-homeassistant-custom-component \ No newline at end of file diff --git a/run.py b/run.py index 90e7116..360311c 100644 --- a/run.py +++ b/run.py @@ -22,7 +22,7 @@ climate.setup_platform(None, config_adapter, add_entities=registry.register, discovery_info=None) -thermostat = registry.first() +thermostat = registry.first thermostat.update() thermostat.update() diff --git a/salusfy/mock_web_client.py b/salusfy/mock_web_client.py index aa5f0d3..967d8a2 100644 --- a/salusfy/mock_web_client.py +++ b/salusfy/mock_web_client.py @@ -20,9 +20,9 @@ class MockWebClient: def __init__(self): """Initialize the client.""" self._state = State() - self._state.target_temperature = 20 - self._state.current_temperature = 15 - self._state.frost = 10 + self._state.target_temperature = 20.1 + self._state.current_temperature = 15.1 + self._state.frost = 10.1 def set_temperature(self, temperature): diff --git a/tests/entity_registry.py b/tests/entity_registry.py index fb653cb..adbaa1e 100644 --- a/tests/entity_registry.py +++ b/tests/entity_registry.py @@ -9,5 +9,6 @@ def register(self, list): def entities(self): return self._entities + @property def first(self): return self._entities[0] \ No newline at end of file diff --git a/tests/test_climate.py b/tests/test_climate.py index c8d1767..0260b61 100644 --- a/tests/test_climate.py +++ b/tests/test_climate.py @@ -7,9 +7,30 @@ from . import mock_config -def test_entity_is_registered(): +def setup_climate_platform(): registry = EntityRegistry() config_adapter = ConfigAdapter(mock_config) climate.setup_platform(None, config_adapter, add_entities=registry.register, discovery_info=None) + return registry + + +def test_entity_is_registered(): + registry = setup_climate_platform() + + assert len(registry.entities) == 1 + + +def test_entity_returns_mock_temperature(): + registry = setup_climate_platform() + + thermostat = registry.first + + assert thermostat.current_temperature == 15.9 + + +def test_entity_returns_mock_target_temperature(): + registry = setup_climate_platform() + + thermostat = registry.first - assert len(registry.entities) == 1 \ No newline at end of file + assert thermostat.target_temperature == 20.1 \ No newline at end of file diff --git a/tests/test_thermostat_entity.py b/tests/test_thermostat_entity.py new file mode 100644 index 0000000..6f0e712 --- /dev/null +++ b/tests/test_thermostat_entity.py @@ -0,0 +1,30 @@ +import pytest +from unittest.mock import MagicMock + +from salusfy import ( ThermostatEntity, State ) + + +def test_entity_is_registered(): + mock_client = MagicMock() + mock_ha_client = MagicMock() + + state = State() + state.current_temperature = 15.3 + state.target_temperature = 33.3 + mock_client.configure_mock( + **{ + "get_state.return_value": state + } + ) + + mock_ha_client.configure_mock( + **{ + "current_temperature.return_value": 21.1 + } + ) + + target = ThermostatEntity('mock', mock_client, mock_ha_client) + + assert target.current_temperature == 21.1 + assert target.target_temperature == 33.3 + # mock_table.insert.assert_called_once() \ No newline at end of file From 3f2deae28bc66aa6b26d63dce9e48aca2a07e792 Mon Sep 17 00:00:00 2001 From: Matt Turner Date: Sun, 17 Dec 2023 16:00:01 +0000 Subject: [PATCH 28/55] Verify salus only called once (#3) --- tests/test_thermostat_entity.py | 59 ++++++++++++++++++++++++++++----- 1 file changed, 51 insertions(+), 8 deletions(-) diff --git a/tests/test_thermostat_entity.py b/tests/test_thermostat_entity.py index 6f0e712..ff28b3c 100644 --- a/tests/test_thermostat_entity.py +++ b/tests/test_thermostat_entity.py @@ -2,29 +2,72 @@ from unittest.mock import MagicMock from salusfy import ( ThermostatEntity, State ) +from homeassistant.components.climate.const import ( + HVAC_MODE_HEAT +) - -def test_entity_is_registered(): - mock_client = MagicMock() - mock_ha_client = MagicMock() +@pytest.fixture +def mock_client(): + mock = MagicMock() state = State() state.current_temperature = 15.3 state.target_temperature = 33.3 - mock_client.configure_mock( + mock.configure_mock( **{ "get_state.return_value": state } ) - mock_ha_client.configure_mock( + return mock + +@pytest.fixture +def mock_ha_client(): + mock = MagicMock() + + mock.configure_mock( **{ "current_temperature.return_value": 21.1 } ) + + return mock +def test_entity_returns_target_temp_from_web_client(mock_client, mock_ha_client): target = ThermostatEntity('mock', mock_client, mock_ha_client) - + + assert target.target_temperature == 33.3 + + +def test_entity_returns_target_temp_from_home_assistant_client(mock_client, mock_ha_client): + target = ThermostatEntity('mock', mock_client, mock_ha_client) + assert target.current_temperature == 21.1 + + +def test_entity_call_salus_client_only_once(mock_client, mock_ha_client): + target = ThermostatEntity('mock', mock_client, mock_ha_client) + + target.update() + target.update() + + mock_client.get_state.assert_called_once() assert target.target_temperature == 33.3 - # mock_table.insert.assert_called_once() \ No newline at end of file + + +def test_entity_delegates_set_temperature_salus_client(mock_client, mock_ha_client): + target = ThermostatEntity('mock', mock_client, mock_ha_client) + + target.set_temperature(temperature=29.9) + + mock_client.set_temperature.assert_called_once_with(29.9) + assert target.target_temperature == 29.9 + + +def test_entity_delegates_set_hvac_mode_to_salus_client(mock_client, mock_ha_client): + target = ThermostatEntity('mock', mock_client, mock_ha_client) + + target.set_hvac_mode(hvac_mode=HVAC_MODE_HEAT) + + mock_client.set_hvac_mode.assert_called_once_with(HVAC_MODE_HEAT) + assert target.hvac_mode == HVAC_MODE_HEAT \ No newline at end of file From d0c141560d020b209c79f86d03938b869f2aa8a7 Mon Sep 17 00:00:00 2001 From: Matt Turner Date: Fri, 16 Feb 2024 16:10:25 +0000 Subject: [PATCH 29/55] Update from source (#4) * Added the option to quickly reload it from dev tools * Update climate.py replaced deprecated consts * Update climate.py * Update climate.py Remove long text from log * Add asyncio * Make unit tests pass * Remove custom_components climate.py * Bump version number --------- Co-authored-by: Dan Timu Co-authored-by: aver-ua Co-authored-by: floringhimie <33951255+floringhimie@users.noreply.github.com> --- requirements.test.txt | 1 + salusfy/climate.py | 14 +++++++++----- salusfy/thermostat_entity.py | 34 ++++++++++++++++------------------ tests/test_climate.py | 30 ++++++++++++++++++++++-------- 4 files changed, 48 insertions(+), 31 deletions(-) diff --git a/requirements.test.txt b/requirements.test.txt index 2c79089..136b030 100644 --- a/requirements.test.txt +++ b/requirements.test.txt @@ -1,3 +1,4 @@ pytest pytest-mock +pytest-asyncio pytest-homeassistant-custom-component \ No newline at end of file diff --git a/salusfy/climate.py b/salusfy/climate.py index 4b7653c..5471e6a 100644 --- a/salusfy/climate.py +++ b/salusfy/climate.py @@ -25,7 +25,9 @@ from homeassistant.components.climate import PLATFORM_SCHEMA -__version__ = "0.1.0" +from homeassistant.helpers.reload import async_setup_reload_service + +__version__ = "0.3.0" _LOGGER = logging.getLogger(__name__) @@ -33,6 +35,8 @@ CONF_NAME = "name" +DOMAIN = "salusfy" +PLATFORMS = ["climate"] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -48,10 +52,10 @@ ) -# def setup_platform(hass, config, add_entities, discovery_info=None): async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up the E-Thermostat platform.""" await async_setup_reload_service(hass, DOMAIN, PLATFORMS) - """Set up the E-Thermostaat platform.""" + name = config.get(CONF_NAME) username = config.get(CONF_USERNAME) password = config.get(CONF_PASSWORD) @@ -63,7 +67,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= if (simulator): _LOGGER.info('Registering Salus simulator...') - add_entities( + async_add_entities( [ThermostatEntity(name, MockWebClient(), MockHaWebClient())] ) else: @@ -71,6 +75,6 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= web_client = WebClient(username, password, id) ha_client = HaWebClient(host, entity_id, access_token) - add_entities( + async_add_entities( [ThermostatEntity(name, web_client, ha_client)] ) diff --git a/salusfy/thermostat_entity.py b/salusfy/thermostat_entity.py index d9fc715..64dad98 100644 --- a/salusfy/thermostat_entity.py +++ b/salusfy/thermostat_entity.py @@ -8,17 +8,15 @@ ) from homeassistant.components.climate.const import ( - CURRENT_HVAC_HEAT, - CURRENT_HVAC_IDLE, - HVAC_MODE_HEAT, - HVAC_MODE_OFF, - SUPPORT_PRESET_MODE, - SUPPORT_TARGET_TEMPERATURE + HVACAction, + HVACMode, + ClimateEntityFeature, + SUPPORT_PRESET_MODE ) from homeassistant.const import ( ATTR_TEMPERATURE, - TEMP_CELSIUS, + UnitOfTemperature, ) try: @@ -26,7 +24,7 @@ except ImportError: from homeassistant.components.climate import ClimateDevice as ClimateEntity -SUPPORT_FLAGS = SUPPORT_TARGET_TEMPERATURE +SUPPORT_FLAGS = ClimateEntityFeature.TARGET_TEMPERATURE class ThermostatEntity(ClimateEntity): """Representation of a Salus Thermostat device.""" @@ -73,7 +71,7 @@ def max_temp(self): @property def temperature_unit(self): """Return the unit of measurement.""" - return TEMP_CELSIUS + return UnitOfTemperature.CELSIUS @property def current_temperature(self): @@ -91,26 +89,26 @@ def hvac_mode(self): """Return hvac operation ie. heat, cool mode.""" try: climate_mode = self._state.current_operation_mode - curr_hvac_mode = HVAC_MODE_OFF + curr_hvac_mode = HVACMode.OFF if climate_mode == STATE_ON: - curr_hvac_mode = HVAC_MODE_HEAT + curr_hvac_mode = HVACMode.HEAT else: - curr_hvac_mode = HVAC_MODE_OFF + curr_hvac_mode = HVACMode.OFF except KeyError: - return HVAC_MODE_OFF + return HVACMode.OFF return curr_hvac_mode @property def hvac_modes(self): """HVAC modes.""" - return [HVAC_MODE_HEAT, HVAC_MODE_OFF] + return [HVACMode.HEAT, HVACMode.OFF] @property def hvac_action(self): """Return the current running hvac operation.""" if self._state.status == STATE_ON: - return CURRENT_HVAC_HEAT - return CURRENT_HVAC_IDLE + return HVACAction.HEATING + return HVACAction.IDLE @property @@ -142,10 +140,10 @@ def set_hvac_mode(self, hvac_mode): self._client.set_hvac_mode(hvac_mode) - if hvac_mode == HVAC_MODE_OFF: + if hvac_mode == HVACMode.OFF: self._state.current_operation_mode = STATE_OFF self._state.status = STATE_OFF - elif hvac_mode == HVAC_MODE_HEAT: + elif hvac_mode == HVACMode.HEAT: self._state.current_operation_mode = STATE_ON self._state.status = STATE_ON diff --git a/tests/test_climate.py b/tests/test_climate.py index 0260b61..b32a974 100644 --- a/tests/test_climate.py +++ b/tests/test_climate.py @@ -6,30 +6,44 @@ from . import mock_config +class MockHass: + @property + def services(self): + return self + + def has_service(self, domain, service): + return False + + def async_register(self, domain, service, admin_handler, schema): + pass -def setup_climate_platform(): +@pytest.mark.asyncio +async def setup_climate_platform(): registry = EntityRegistry() config_adapter = ConfigAdapter(mock_config) - climate.setup_platform(None, config_adapter, add_entities=registry.register, discovery_info=None) + await climate.async_setup_platform(MockHass(), config_adapter, async_add_entities=registry.register, discovery_info=None) return registry -def test_entity_is_registered(): - registry = setup_climate_platform() +@pytest.mark.asyncio +async def test_entity_is_registered(): + registry = await setup_climate_platform() assert len(registry.entities) == 1 -def test_entity_returns_mock_temperature(): - registry = setup_climate_platform() +@pytest.mark.asyncio +async def test_entity_returns_mock_temperature(): + registry = await setup_climate_platform() thermostat = registry.first assert thermostat.current_temperature == 15.9 -def test_entity_returns_mock_target_temperature(): - registry = setup_climate_platform() +@pytest.mark.asyncio +async def test_entity_returns_mock_target_temperature(): + registry = await setup_climate_platform() thermostat = registry.first From 20f23890bf579c0f1258f72985241cad0b32ceee Mon Sep 17 00:00:00 2001 From: Matt Turner Date: Fri, 16 Feb 2024 22:01:18 +0000 Subject: [PATCH 30/55] Create wrapper client (#5) --- salusfy/__init__.py | 6 +- salusfy/client.py | 50 +++++++++++++ salusfy/climate.py | 45 ++++++++---- ...web_client.py => ha_temperature_client.py} | 9 ++- salusfy/simulator/__init__.py | 2 + .../temperature_client.py} | 6 +- .../web_client.py} | 17 +++-- salusfy/thermostat_entity.py | 7 +- tests/config_adapter.py | 3 + tests/entity_registry.py | 2 +- tests/mock_config.py | 1 + tests/test_client.py | 73 +++++++++++++++++++ tests/test_climate.py | 1 + tests/test_thermostat_entity.py | 39 ++-------- 14 files changed, 193 insertions(+), 68 deletions(-) create mode 100644 salusfy/client.py rename salusfy/{ha_web_client.py => ha_temperature_client.py} (80%) create mode 100644 salusfy/simulator/__init__.py rename salusfy/{mock_ha_web_client.py => simulator/temperature_client.py} (52%) rename salusfy/{mock_web_client.py => simulator/web_client.py} (80%) create mode 100644 tests/test_client.py diff --git a/salusfy/__init__.py b/salusfy/__init__.py index d0d847a..1369929 100644 --- a/salusfy/__init__.py +++ b/salusfy/__init__.py @@ -8,7 +8,7 @@ STATE_ON, STATE_OFF ) -from .mock_web_client import MockWebClient + from .thermostat_entity import ThermostatEntity -from .ha_web_client import HaWebClient -from .mock_ha_web_client import MockHaWebClient \ No newline at end of file +from .client import Client +from .ha_temperature_client import HaTemperatureClient diff --git a/salusfy/client.py b/salusfy/client.py new file mode 100644 index 0000000..49000e5 --- /dev/null +++ b/salusfy/client.py @@ -0,0 +1,50 @@ +""" +Client which wraps the web client but handles +the retrieval of current temperature by calling +a specialized client. +""" +import logging +from .web_client import WebClient +from .ha_temperature_client import HaTemperatureClient + +_LOGGER = logging.getLogger(__name__) + +class Client: + """Mocks requests to Salus web application""" + + def __init__(self, web_client : WebClient, temperature_client : HaTemperatureClient): + """Initialize the client.""" + self._state = None + self._web_client = web_client + self._temperature_client = temperature_client + + self.get_state() + + + def set_temperature(self, temperature): + """Set new target temperature.""" + + _LOGGER.info("Delegating set_temperature to web client...") + + self._web_client.set_temperature(temperature) + + + def set_hvac_mode(self, hvac_mode): + """Set HVAC mode, via URL commands.""" + + _LOGGER.info("Delegating set_hvac_mode to web client...") + + self._web_client.set_hvac_mode(hvac_mode) + + + def get_state(self): + """Retrieves the status""" + + if self._state is None: + _LOGGER.info("Delegating get_state to web client...") + self._state = self._web_client.get_state() + + _LOGGER.info("Updating current temperature from temperature client...") + self._state.current_temperature = self._temperature_client.current_temperature() + + return self._state diff --git a/salusfy/climate.py b/salusfy/climate.py index 5471e6a..5a2c53f 100644 --- a/salusfy/climate.py +++ b/salusfy/climate.py @@ -20,8 +20,11 @@ ) CONF_SIMULATOR = 'simulator' +CONF_ENABLE_TEMPERATURE_CLIENT = 'enable_temperature_client' -from . import ( ThermostatEntity, WebClient, MockWebClient, HaWebClient, MockHaWebClient ) +from . import ( ThermostatEntity, Client, WebClient, HaTemperatureClient ) + +from . import simulator from homeassistant.components.climate import PLATFORM_SCHEMA @@ -56,25 +59,37 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= """Set up the E-Thermostat platform.""" await async_setup_reload_service(hass, DOMAIN, PLATFORMS) + client = create_client_from(config) + name = config.get(CONF_NAME) + await async_add_entities( + [ThermostatEntity(name, client)] + ) + + +def create_client_from(config): username = config.get(CONF_USERNAME) password = config.get(CONF_PASSWORD) id = config.get(CONF_ID) - simulator = config.get(CONF_SIMULATOR) + enable_simulator = config.get(CONF_SIMULATOR) + enable_temperature_client = config.get(CONF_ENABLE_TEMPERATURE_CLIENT) entity_id = config.get(CONF_ENTITY_ID) host = config.get(CONF_HOST) access_token = config.get(CONF_ACCESS_TOKEN) - if (simulator): - _LOGGER.info('Registering Salus simulator...') - async_add_entities( - [ThermostatEntity(name, MockWebClient(), MockHaWebClient())] - ) - else: - _LOGGER.info('Registering Salus Thermostat climate entity...') - web_client = WebClient(username, password, id) - ha_client = HaWebClient(host, entity_id, access_token) - - async_add_entities( - [ThermostatEntity(name, web_client, ha_client)] - ) + if enable_simulator: + _LOGGER.info('Registering Salus Thermostat client simulator...') + + return Client(simulator.WebClient(), simulator.TemperatureClient()) + + web_client = WebClient(username, password, id) + + if not enable_temperature_client: + _LOGGER.info('Registering Salus Thermostat client...') + + return web_client + + _LOGGER.info('Registering Salus Thermostat client with Temperature client...') + + ha_client = HaTemperatureClient(host, entity_id, access_token) + return Client(web_client, ha_client) diff --git a/salusfy/ha_web_client.py b/salusfy/ha_temperature_client.py similarity index 80% rename from salusfy/ha_web_client.py rename to salusfy/ha_temperature_client.py index 577187f..6ee8ce3 100644 --- a/salusfy/ha_web_client.py +++ b/salusfy/ha_temperature_client.py @@ -1,6 +1,11 @@ from requests import get -class HaWebClient: +""" +Retrieves the current temperature from +another entity from the Home Assistant API +""" + +class HaTemperatureClient: def __init__(self, host, entity_id, access_token): self._entity_id = entity_id self._host = host @@ -8,7 +13,7 @@ def __init__(self, host, entity_id, access_token): def current_temperature(self): - """Gets the current temperature from """ + """Gets the current temperature from HA""" url = F"http://{self._host}:8123/api/states/{self._entity_id}" diff --git a/salusfy/simulator/__init__.py b/salusfy/simulator/__init__.py new file mode 100644 index 0000000..983a8d7 --- /dev/null +++ b/salusfy/simulator/__init__.py @@ -0,0 +1,2 @@ +from .temperature_client import TemperatureClient +from .web_client import WebClient \ No newline at end of file diff --git a/salusfy/mock_ha_web_client.py b/salusfy/simulator/temperature_client.py similarity index 52% rename from salusfy/mock_ha_web_client.py rename to salusfy/simulator/temperature_client.py index 91a6834..2e70faa 100644 --- a/salusfy/mock_ha_web_client.py +++ b/salusfy/simulator/temperature_client.py @@ -1,5 +1,7 @@ - -class MockHaWebClient: +""" +Adds support for simulating the Salus Thermostats. +""" +class TemperatureClient: def __init__(self): pass diff --git a/salusfy/mock_web_client.py b/salusfy/simulator/web_client.py similarity index 80% rename from salusfy/mock_web_client.py rename to salusfy/simulator/web_client.py index 967d8a2..2281178 100644 --- a/salusfy/mock_web_client.py +++ b/salusfy/simulator/web_client.py @@ -1,12 +1,14 @@ """ -Adds support for the Salus Thermostat units. +Adds support for simulating the Salus Thermostats. """ import logging -from . import ( +from homeassistant.components.climate.const import ( + HVACMode, +) + +from .. import ( State, - HVAC_MODE_HEAT, - HVAC_MODE_OFF, STATE_ON, STATE_OFF ) @@ -14,7 +16,8 @@ _LOGGER = logging.getLogger(__name__) -class MockWebClient: + +class WebClient: """Mocks requests to Salus web application""" def __init__(self): @@ -38,9 +41,9 @@ def set_hvac_mode(self, hvac_mode): _LOGGER.info("Setting the HVAC mode to %s...", hvac_mode) - if hvac_mode == HVAC_MODE_OFF: + if hvac_mode == HVACMode.OFF: self._state.current_operation_mode = STATE_OFF - elif hvac_mode == HVAC_MODE_HEAT: + elif hvac_mode == HVACMode.HEAT: self._state.current_operation_mode = STATE_ON diff --git a/salusfy/thermostat_entity.py b/salusfy/thermostat_entity.py index 64dad98..3be9921 100644 --- a/salusfy/thermostat_entity.py +++ b/salusfy/thermostat_entity.py @@ -29,11 +29,10 @@ class ThermostatEntity(ClimateEntity): """Representation of a Salus Thermostat device.""" - def __init__(self, name, client, ha_client): + def __init__(self, name, client): """Initialize the thermostat.""" self._name = name self._client = client - self._ha_client = ha_client self._state = None self.update() @@ -150,6 +149,4 @@ def set_hvac_mode(self, hvac_mode): def update(self): """Get the latest state data.""" - if self._state is None: - self._state = self._client.get_state() - self._state.current_temperature = self._ha_client.current_temperature() \ No newline at end of file + self._state = self._client.get_state() \ No newline at end of file diff --git a/tests/config_adapter.py b/tests/config_adapter.py index 55ea6b2..f3f40bd 100644 --- a/tests/config_adapter.py +++ b/tests/config_adapter.py @@ -19,6 +19,9 @@ def get(self, key): if (key == 'simulator'): return self._config.SIMULATOR + if (key == 'enable_temperature_client'): + return self._config.ENABLE_TEMPERATURE_CLIENT + if (key == 'host'): return self._config.HOST diff --git a/tests/entity_registry.py b/tests/entity_registry.py index adbaa1e..43d153d 100644 --- a/tests/entity_registry.py +++ b/tests/entity_registry.py @@ -2,7 +2,7 @@ class EntityRegistry: def __init__(self): self._entities = [] - def register(self, list): + async def register(self, list): self._entities.extend(list) @property diff --git a/tests/mock_config.py b/tests/mock_config.py index 3d4e823..5af154f 100644 --- a/tests/mock_config.py +++ b/tests/mock_config.py @@ -4,4 +4,5 @@ ENTITY_ID = "sensor.everything_presence_one_temperature" ACCESS_TOKEN = "some-secret" SIMULATOR = True +ENABLE_TEMPERATURE_CLIENT = True HOST = "192.168.0.99" \ No newline at end of file diff --git a/tests/test_client.py b/tests/test_client.py new file mode 100644 index 0000000..c044a17 --- /dev/null +++ b/tests/test_client.py @@ -0,0 +1,73 @@ +import pytest +from unittest.mock import MagicMock + +from salusfy import ( Client, State ) +from homeassistant.components.climate.const import ( + HVAC_MODE_HEAT +) + +@pytest.fixture +def mock_client(): + mock = MagicMock() + + state = State() + state.current_temperature = 15.3 + state.target_temperature = 33.3 + mock.configure_mock( + **{ + "get_state.return_value": state + } + ) + + return mock + + +@pytest.fixture +def mock_ha_client(): + mock = MagicMock() + + mock.configure_mock( + **{ + "current_temperature.return_value": 21.1 + } + ) + + return mock + + +def test_entity_returns_target_temp_from_web_client(mock_client, mock_ha_client): + target = Client(mock_client, mock_ha_client) + + assert target.get_state().target_temperature == 33.3 + + +def test_entity_returns_target_temp_from_home_assistant_client(mock_client, mock_ha_client): + target = Client(mock_client, mock_ha_client) + + assert target.get_state().current_temperature == 21.1 + + +def test_entity_call_salus_client_only_once(mock_client, mock_ha_client): + target = Client(mock_client, mock_ha_client) + + target.get_state() + target.get_state() + + mock_client.get_state.assert_called_once() + assert target.get_state().target_temperature == 33.3 + + +def test_entity_delegates_set_temperature_salus_client(mock_client, mock_ha_client): + target = Client(mock_client, mock_ha_client) + + target.set_temperature(temperature=29.9) + + mock_client.set_temperature.assert_called_once_with(29.9) + + +def test_entity_delegates_set_hvac_mode_to_salus_client(mock_client, mock_ha_client): + target = Client(mock_client, mock_ha_client) + + target.set_hvac_mode(hvac_mode=HVAC_MODE_HEAT) + + mock_client.set_hvac_mode.assert_called_once_with(HVAC_MODE_HEAT) \ No newline at end of file diff --git a/tests/test_climate.py b/tests/test_climate.py index b32a974..ed4e754 100644 --- a/tests/test_climate.py +++ b/tests/test_climate.py @@ -17,6 +17,7 @@ def has_service(self, domain, service): def async_register(self, domain, service, admin_handler, schema): pass + @pytest.mark.asyncio async def setup_climate_platform(): registry = EntityRegistry() diff --git a/tests/test_thermostat_entity.py b/tests/test_thermostat_entity.py index ff28b3c..7f41380 100644 --- a/tests/test_thermostat_entity.py +++ b/tests/test_thermostat_entity.py @@ -21,42 +21,15 @@ def mock_client(): return mock -@pytest.fixture -def mock_ha_client(): - mock = MagicMock() - - mock.configure_mock( - **{ - "current_temperature.return_value": 21.1 - } - ) - - return mock - -def test_entity_returns_target_temp_from_web_client(mock_client, mock_ha_client): - target = ThermostatEntity('mock', mock_client, mock_ha_client) - - assert target.target_temperature == 33.3 - - -def test_entity_returns_target_temp_from_home_assistant_client(mock_client, mock_ha_client): - target = ThermostatEntity('mock', mock_client, mock_ha_client) - - assert target.current_temperature == 21.1 - - -def test_entity_call_salus_client_only_once(mock_client, mock_ha_client): - target = ThermostatEntity('mock', mock_client, mock_ha_client) - target.update() - target.update() +def test_entity_returns_target_temp_from_web_client(mock_client): + target = ThermostatEntity('mock', mock_client) - mock_client.get_state.assert_called_once() assert target.target_temperature == 33.3 -def test_entity_delegates_set_temperature_salus_client(mock_client, mock_ha_client): - target = ThermostatEntity('mock', mock_client, mock_ha_client) +def test_entity_delegates_set_temperature_web_client(mock_client): + target = ThermostatEntity('mock', mock_client) target.set_temperature(temperature=29.9) @@ -64,8 +37,8 @@ def test_entity_delegates_set_temperature_salus_client(mock_client, mock_ha_clie assert target.target_temperature == 29.9 -def test_entity_delegates_set_hvac_mode_to_salus_client(mock_client, mock_ha_client): - target = ThermostatEntity('mock', mock_client, mock_ha_client) +def test_entity_delegates_set_hvac_mode_to_web_client(mock_client): + target = ThermostatEntity('mock', mock_client) target.set_hvac_mode(hvac_mode=HVAC_MODE_HEAT) From 476776c1d3dda477b846f1b6ccc4c0940a687d30 Mon Sep 17 00:00:00 2001 From: Matt Turner Date: Sat, 17 Feb 2024 07:48:25 +0000 Subject: [PATCH 31/55] Add devcontainer (#7) --- ,gitattributes | 3 +++ .devcontainer/devcontainer.json | 17 +++++++++++++++++ .github/dependabot.yml | 12 ++++++++++++ .gitignore | 3 ++- 4 files changed, 34 insertions(+), 1 deletion(-) create mode 100644 ,gitattributes create mode 100644 .devcontainer/devcontainer.json create mode 100644 .github/dependabot.yml diff --git a/,gitattributes b/,gitattributes new file mode 100644 index 0000000..5dc46e6 --- /dev/null +++ b/,gitattributes @@ -0,0 +1,3 @@ +* text=auto eol=lf +*.{cmd,[cC][mM][dD]} text eol=crlf +*.{bat,[bB][aA][tT]} text eol=crlf \ No newline at end of file diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..37f9950 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,17 @@ +// For format details, see https://aka.ms/devcontainer.json. For config options, see the +// README at: https://github.com/devcontainers/templates/tree/main/src/python +{ + "name": "Python 3", + // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile + "image": "mcr.microsoft.com/devcontainers/python:1-3.12-bullseye", + // Features to add to the dev container. More info: https://containers.dev/features. + // "features": {}, + // Use 'forwardPorts' to make a list of ports inside the container available locally. + // "forwardPorts": [], + // Use 'postCreateCommand' to run commands after the container is created. + "postCreateCommand": "pip3 install --user -r requirements.txt -r requirements.test.txt" + // Configure tool-specific properties. + // "customizations": {}, + // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. + // "remoteUser": "root" +} \ No newline at end of file diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..f33a02c --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,12 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for more information: +# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates +# https://containers.dev/guide/dependabot + +version: 2 +updates: + - package-ecosystem: "devcontainers" + directory: "/" + schedule: + interval: weekly diff --git a/.gitignore b/.gitignore index 9da8b8c..2b816fd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ config.py *.pyc -__pycache__ \ No newline at end of file +__pycache__ +.pytest_cache/ \ No newline at end of file From 651030e73e401fdec6f01c2cdcb46f861fe837cd Mon Sep 17 00:00:00 2001 From: Matt Turner Date: Sat, 17 Feb 2024 08:00:33 +0000 Subject: [PATCH 32/55] Fix line endings --- ,gitattributes | 4 +- .devcontainer/devcontainer.json | 32 +-- .github/dependabot.yml | 24 +- .github/workflows/ci.yml | 72 +++--- .gitignore | 6 +- .vscode/settings.json | 7 + Dockerfile | 22 +- README.md | 102 ++++---- config.sample.py | 14 +- install.sh | 14 +- requirements.test.txt | 6 +- requirements.txt | 2 +- run.py | 70 +++--- salusfy/__init__.py | 28 +-- salusfy/client.py | 100 ++++---- salusfy/climate.py | 182 +++++++------- salusfy/ha_temperature_client.py | 70 +++--- salusfy/manifest.json | 24 +- salusfy/simulator/__init__.py | 2 +- salusfy/simulator/temperature_client.py | 16 +- salusfy/simulator/web_client.py | 104 ++++---- salusfy/state.py | 14 +- salusfy/thermostat_entity.py | 302 +++++++++++----------- salusfy/web_client.py | 320 ++++++++++++------------ test.ps1 | 8 +- tests/config_adapter.py | 62 ++--- tests/entity_registry.py | 26 +- tests/mock_config.py | 14 +- tests/test_client.py | 144 +++++------ tests/test_climate.py | 100 ++++---- tests/test_thermostat_entity.py | 90 +++---- 31 files changed, 994 insertions(+), 987 deletions(-) create mode 100644 .vscode/settings.json diff --git a/,gitattributes b/,gitattributes index 5dc46e6..8f76d9e 100644 --- a/,gitattributes +++ b/,gitattributes @@ -1,3 +1,3 @@ -* text=auto eol=lf -*.{cmd,[cC][mM][dD]} text eol=crlf +* text=auto eol=lf +*.{cmd,[cC][mM][dD]} text eol=crlf *.{bat,[bB][aA][tT]} text eol=crlf \ No newline at end of file diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 37f9950..12315e0 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,17 +1,17 @@ -// For format details, see https://aka.ms/devcontainer.json. For config options, see the -// README at: https://github.com/devcontainers/templates/tree/main/src/python -{ - "name": "Python 3", - // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile - "image": "mcr.microsoft.com/devcontainers/python:1-3.12-bullseye", - // Features to add to the dev container. More info: https://containers.dev/features. - // "features": {}, - // Use 'forwardPorts' to make a list of ports inside the container available locally. - // "forwardPorts": [], - // Use 'postCreateCommand' to run commands after the container is created. - "postCreateCommand": "pip3 install --user -r requirements.txt -r requirements.test.txt" - // Configure tool-specific properties. - // "customizations": {}, - // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. - // "remoteUser": "root" +// For format details, see https://aka.ms/devcontainer.json. For config options, see the +// README at: https://github.com/devcontainers/templates/tree/main/src/python +{ + "name": "Python 3", + // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile + "image": "mcr.microsoft.com/devcontainers/python:1-3.12-bullseye", + // Features to add to the dev container. More info: https://containers.dev/features. + // "features": {}, + // Use 'forwardPorts' to make a list of ports inside the container available locally. + // "forwardPorts": [], + // Use 'postCreateCommand' to run commands after the container is created. + "postCreateCommand": "pip3 install --user -r requirements.txt -r requirements.test.txt" + // Configure tool-specific properties. + // "customizations": {}, + // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. + // "remoteUser": "root" } \ No newline at end of file diff --git a/.github/dependabot.yml b/.github/dependabot.yml index f33a02c..20cb428 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,12 +1,12 @@ -# To get started with Dependabot version updates, you'll need to specify which -# package ecosystems to update and where the package manifests are located. -# Please see the documentation for more information: -# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates -# https://containers.dev/guide/dependabot - -version: 2 -updates: - - package-ecosystem: "devcontainers" - directory: "/" - schedule: - interval: weekly +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for more information: +# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates +# https://containers.dev/guide/dependabot + +version: 2 +updates: + - package-ecosystem: "devcontainers" + directory: "/" + schedule: + interval: weekly diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3dae740..cc2a3e7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,37 +1,37 @@ -name: CI - -on: - push: - branches: [ "main" ] - pull_request: - branches: [ "main" ] - -permissions: - contents: read - -jobs: - build: - - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v3 - - name: Set up Python 3.10 - uses: actions/setup-python@v3 - with: - python-version: "3.10" - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install flake8 pytest - if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - if [ -f requirements.test.txt ]; then pip install -r requirements.test.txt; fi - - name: Lint with flake8 - run: | - # stop the build if there are Python syntax errors or undefined names - flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics - # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide - flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics - - name: Test with pytest - run: | +name: CI + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +permissions: + contents: read + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + - name: Set up Python 3.10 + uses: actions/setup-python@v3 + with: + python-version: "3.10" + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install flake8 pytest + if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + if [ -f requirements.test.txt ]; then pip install -r requirements.test.txt; fi + - name: Lint with flake8 + run: | + # stop the build if there are Python syntax errors or undefined names + flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics + # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide + flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + - name: Test with pytest + run: | pytest \ No newline at end of file diff --git a/.gitignore b/.gitignore index 2b816fd..e37a8ef 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ -config.py -*.pyc -__pycache__ +config.py +*.pyc +__pycache__ .pytest_cache/ \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..3e99ede --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,7 @@ +{ + "python.testing.pytestArgs": [ + "." + ], + "python.testing.unittestEnabled": false, + "python.testing.pytestEnabled": true +} \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 2baad14..0481401 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,12 +1,12 @@ -FROM python:3.9.10 -ENV PIP_DISABLE_ROOT_WARNING=1 -RUN python -m pip install --upgrade pip -COPY requirements.test.txt requirements.test.txt -RUN pip install -r requirements.test.txt -COPY requirements.txt requirements.txt -RUN pip install -r requirements.txt -VOLUME /app -WORKDIR /app -ENV NO_COLOR=yes_please -ENV LANG=C +FROM python:3.9.10 +ENV PIP_DISABLE_ROOT_WARNING=1 +RUN python -m pip install --upgrade pip +COPY requirements.test.txt requirements.test.txt +RUN pip install -r requirements.test.txt +COPY requirements.txt requirements.txt +RUN pip install -r requirements.txt +VOLUME /app +WORKDIR /app +ENV NO_COLOR=yes_please +ENV LANG=C CMD ["python", "-m", "pytest"] \ No newline at end of file diff --git a/README.md b/README.md index 09cd426..db7b0c3 100644 --- a/README.md +++ b/README.md @@ -1,52 +1,52 @@ -# Home-Assistant Custom Components -Custom Components for Home-Assistant (http://www.home-assistant.io) - -# Salus Thermostat Climate Component -My device is RT301i, it is working with it500 thermostat, the ideea is simple if you have a Salus Thermostat and you are able to login to salus-it500.com and control it from this page, this custom component should work. - -## Component to interface with the salus-it500.com. -It reads the Current Temperature, Set Temperature, Current HVAC Mode, Current Relay Mode. - -Keep in mind this is my first custom component and this is also the first version of this Salusfy so it can have bugs. Sorry for that. - -**** This is not an official integration. - -### Installation -* If not exist, in config/custom_components/ create a directory called salusfy -* Copy all files in salusfy to your config/custom_components/salusfy/ directory. -* Configure with config below. -* Restart Home-Assistant. - -### Usage -To use this component in your installation, add the following to your configuration.yaml file: - -### Example configuration.yaml entry - -``` -climate: - - platform: salusfy - username: "EMAIL" - password: "PASSWORD" - id: "DEVICE_ID" - entity_id: "sensor.temperature" - access_token: "ha_long_lived_token" -``` -![image](https://user-images.githubusercontent.com/33951255/140300295-4915a18f-f5d4-4957-b513-59d7736cc52a.png) -![image](https://user-images.githubusercontent.com/33951255/140303472-fd38b9e4-5c33-408f-afef-25547c39551c.png) - - -### Getting the DEVICE_ID -1. Loggin to https://salus-it500.com with email and password used in the mobile app (in my case RT301i) -2. Click on the device -3. In the next page you will be able to see the device ID in the page URL -4. Copy the device ID from the URL -![image](https://user-images.githubusercontent.com/33951255/140301260-151b6af9-dbc4-4e90-a14e-29018fe2e482.png) - - -### Known issues -Due to how chatty the HA integration is, the salus-it500.com server may start blocking your public IP address (and rightly so). This will prevent the gateway and mobile client from connecting. This implementation aims to resolve this by: - -* suppressing requests to Salus in many circumstances -* querying another entity for current temperature - +# Home-Assistant Custom Components +Custom Components for Home-Assistant (http://www.home-assistant.io) + +# Salus Thermostat Climate Component +My device is RT301i, it is working with it500 thermostat, the ideea is simple if you have a Salus Thermostat and you are able to login to salus-it500.com and control it from this page, this custom component should work. + +## Component to interface with the salus-it500.com. +It reads the Current Temperature, Set Temperature, Current HVAC Mode, Current Relay Mode. + +Keep in mind this is my first custom component and this is also the first version of this Salusfy so it can have bugs. Sorry for that. + +**** This is not an official integration. + +### Installation +* If not exist, in config/custom_components/ create a directory called salusfy +* Copy all files in salusfy to your config/custom_components/salusfy/ directory. +* Configure with config below. +* Restart Home-Assistant. + +### Usage +To use this component in your installation, add the following to your configuration.yaml file: + +### Example configuration.yaml entry + +``` +climate: + - platform: salusfy + username: "EMAIL" + password: "PASSWORD" + id: "DEVICE_ID" + entity_id: "sensor.temperature" + access_token: "ha_long_lived_token" +``` +![image](https://user-images.githubusercontent.com/33951255/140300295-4915a18f-f5d4-4957-b513-59d7736cc52a.png) +![image](https://user-images.githubusercontent.com/33951255/140303472-fd38b9e4-5c33-408f-afef-25547c39551c.png) + + +### Getting the DEVICE_ID +1. Loggin to https://salus-it500.com with email and password used in the mobile app (in my case RT301i) +2. Click on the device +3. In the next page you will be able to see the device ID in the page URL +4. Copy the device ID from the URL +![image](https://user-images.githubusercontent.com/33951255/140301260-151b6af9-dbc4-4e90-a14e-29018fe2e482.png) + + +### Known issues +Due to how chatty the HA integration is, the salus-it500.com server may start blocking your public IP address (and rightly so). This will prevent the gateway and mobile client from connecting. This implementation aims to resolve this by: + +* suppressing requests to Salus in many circumstances +* querying another entity for current temperature + The effect of this is that the target temperature/status values may be out of date if it has been outside of HA, but the main control features (target temperature, set status etc) will still work. \ No newline at end of file diff --git a/config.sample.py b/config.sample.py index 7181c86..61dfa3b 100644 --- a/config.sample.py +++ b/config.sample.py @@ -1,8 +1,8 @@ -# this file is used by the run.py test script -# it is not required by the custom component - -# copy this file to config.py and replace the values - -USERNAME = "replace" -PASSWORD = "replace" +# this file is used by the run.py test script +# it is not required by the custom component + +# copy this file to config.py and replace the values + +USERNAME = "replace" +PASSWORD = "replace" DEVICE_ID = "replace" \ No newline at end of file diff --git a/install.sh b/install.sh index 03f73b8..433a451 100644 --- a/install.sh +++ b/install.sh @@ -1,8 +1,8 @@ - -# expects the repository to be cloned within the homeassistant directory - -echo "Copying all files from the cloned repo to the Home Assistant custom_components directory..." - -cp --verbose ./salusfy/*.* ../custom_components/salusfy - + +# expects the repository to be cloned within the homeassistant directory + +echo "Copying all files from the cloned repo to the Home Assistant custom_components directory..." + +cp --verbose ./salusfy/*.* ../custom_components/salusfy + echo "Restart Home Assistant to apply the changes" \ No newline at end of file diff --git a/requirements.test.txt b/requirements.test.txt index 136b030..2698041 100644 --- a/requirements.test.txt +++ b/requirements.test.txt @@ -1,4 +1,4 @@ -pytest -pytest-mock -pytest-asyncio +pytest +pytest-mock +pytest-asyncio pytest-homeassistant-custom-component \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index f229360..bebaa03 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1 @@ -requests +requests diff --git a/run.py b/run.py index 360311c..3399743 100644 --- a/run.py +++ b/run.py @@ -1,36 +1,36 @@ -# To run this script to test the component: -# 1 Copy config.sample.py to config.py -# 2 Replace the username/password/deviceid (don't worry, this file will be ignored by git) -# 3 Run with `python run.py` - -from salusfy import climate -from tests.config_adapter import ConfigAdapter -from tests.entity_registry import EntityRegistry - -from homeassistant.components.climate.const import ( - HVAC_MODE_HEAT, - HVAC_MODE_OFF, -) - -import config - -import logging -logging.basicConfig(format='%(levelname)s: %(message)s', level=logging.DEBUG) - -registry = EntityRegistry() -config_adapter = ConfigAdapter(config) - -climate.setup_platform(None, config_adapter, add_entities=registry.register, discovery_info=None) - -thermostat = registry.first - -thermostat.update() -thermostat.update() - -thermostat.set_hvac_mode(HVAC_MODE_HEAT) -thermostat.set_temperature(temperature=9.8) - -print("Current: " + str(thermostat.current_temperature)) -print("Target: " + str(thermostat.target_temperature)) -print("HVAC Action: " + thermostat.hvac_action) +# To run this script to test the component: +# 1 Copy config.sample.py to config.py +# 2 Replace the username/password/deviceid (don't worry, this file will be ignored by git) +# 3 Run with `python run.py` + +from salusfy import climate +from tests.config_adapter import ConfigAdapter +from tests.entity_registry import EntityRegistry + +from homeassistant.components.climate.const import ( + HVAC_MODE_HEAT, + HVAC_MODE_OFF, +) + +import config + +import logging +logging.basicConfig(format='%(levelname)s: %(message)s', level=logging.DEBUG) + +registry = EntityRegistry() +config_adapter = ConfigAdapter(config) + +climate.setup_platform(None, config_adapter, add_entities=registry.register, discovery_info=None) + +thermostat = registry.first + +thermostat.update() +thermostat.update() + +thermostat.set_hvac_mode(HVAC_MODE_HEAT) +thermostat.set_temperature(temperature=9.8) + +print("Current: " + str(thermostat.current_temperature)) +print("Target: " + str(thermostat.target_temperature)) +print("HVAC Action: " + thermostat.hvac_action) print("HVAC Mode: " + thermostat.hvac_mode) \ No newline at end of file diff --git a/salusfy/__init__.py b/salusfy/__init__.py index 1369929..c2d7794 100644 --- a/salusfy/__init__.py +++ b/salusfy/__init__.py @@ -1,14 +1,14 @@ -"""The Salus component.""" - -from .state import State -from .web_client import ( - WebClient, - HVAC_MODE_HEAT, - HVAC_MODE_OFF, - STATE_ON, - STATE_OFF -) - -from .thermostat_entity import ThermostatEntity -from .client import Client -from .ha_temperature_client import HaTemperatureClient +"""The Salus component.""" + +from .state import State +from .web_client import ( + WebClient, + HVAC_MODE_HEAT, + HVAC_MODE_OFF, + STATE_ON, + STATE_OFF +) + +from .thermostat_entity import ThermostatEntity +from .client import Client +from .ha_temperature_client import HaTemperatureClient diff --git a/salusfy/client.py b/salusfy/client.py index 49000e5..5776b55 100644 --- a/salusfy/client.py +++ b/salusfy/client.py @@ -1,50 +1,50 @@ -""" -Client which wraps the web client but handles -the retrieval of current temperature by calling -a specialized client. -""" -import logging -from .web_client import WebClient -from .ha_temperature_client import HaTemperatureClient - -_LOGGER = logging.getLogger(__name__) - -class Client: - """Mocks requests to Salus web application""" - - def __init__(self, web_client : WebClient, temperature_client : HaTemperatureClient): - """Initialize the client.""" - self._state = None - self._web_client = web_client - self._temperature_client = temperature_client - - self.get_state() - - - def set_temperature(self, temperature): - """Set new target temperature.""" - - _LOGGER.info("Delegating set_temperature to web client...") - - self._web_client.set_temperature(temperature) - - - def set_hvac_mode(self, hvac_mode): - """Set HVAC mode, via URL commands.""" - - _LOGGER.info("Delegating set_hvac_mode to web client...") - - self._web_client.set_hvac_mode(hvac_mode) - - - def get_state(self): - """Retrieves the status""" - - if self._state is None: - _LOGGER.info("Delegating get_state to web client...") - self._state = self._web_client.get_state() - - _LOGGER.info("Updating current temperature from temperature client...") - self._state.current_temperature = self._temperature_client.current_temperature() - - return self._state +""" +Client which wraps the web client but handles +the retrieval of current temperature by calling +a specialized client. +""" +import logging +from .web_client import WebClient +from .ha_temperature_client import HaTemperatureClient + +_LOGGER = logging.getLogger(__name__) + +class Client: + """Mocks requests to Salus web application""" + + def __init__(self, web_client : WebClient, temperature_client : HaTemperatureClient): + """Initialize the client.""" + self._state = None + self._web_client = web_client + self._temperature_client = temperature_client + + self.get_state() + + + def set_temperature(self, temperature): + """Set new target temperature.""" + + _LOGGER.info("Delegating set_temperature to web client...") + + self._web_client.set_temperature(temperature) + + + def set_hvac_mode(self, hvac_mode): + """Set HVAC mode, via URL commands.""" + + _LOGGER.info("Delegating set_hvac_mode to web client...") + + self._web_client.set_hvac_mode(hvac_mode) + + + def get_state(self): + """Retrieves the status""" + + if self._state is None: + _LOGGER.info("Delegating get_state to web client...") + self._state = self._web_client.get_state() + + _LOGGER.info("Updating current temperature from temperature client...") + self._state.current_temperature = self._temperature_client.current_temperature() + + return self._state diff --git a/salusfy/climate.py b/salusfy/climate.py index 8279168..89748f1 100644 --- a/salusfy/climate.py +++ b/salusfy/climate.py @@ -1,91 +1,91 @@ -""" -Adds support for the Salus Thermostat units. -""" -import logging - -import homeassistant.helpers.config_validation as cv -import voluptuous as vol - -from homeassistant.const import ( - CONF_PASSWORD, - CONF_USERNAME, - CONF_ID, - CONF_ENTITY_ID, - CONF_ACCESS_TOKEN, - CONF_HOST -) - -CONF_SIMULATOR = 'simulator' -CONF_ENABLE_TEMPERATURE_CLIENT = 'enable_temperature_client' - -from . import ( ThermostatEntity, Client, WebClient, HaTemperatureClient ) - -from . import simulator - -from homeassistant.components.climate import PLATFORM_SCHEMA - -from homeassistant.helpers.reload import async_setup_reload_service - -__version__ = "0.3.0" - -_LOGGER = logging.getLogger(__name__) - -DEFAULT_NAME = "Salus Thermostat" - -CONF_NAME = "name" - -DOMAIN = "salusfy" -PLATFORMS = ["climate"] - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - vol.Required(CONF_ID): cv.string, - vol.Optional(CONF_SIMULATOR, default=False): cv.boolean, - vol.Required(CONF_ENTITY_ID): cv.string, - vol.Required(CONF_ACCESS_TOKEN): cv.string, - vol.Optional(CONF_HOST, default='localhost'): cv.string - } -) - - -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Set up the E-Thermostat platform.""" - await async_setup_reload_service(hass, DOMAIN, PLATFORMS) - - client = create_client_from(config) - - name = config.get(CONF_NAME) - await async_add_entities( - [ThermostatEntity(name, client)] - ) - - -def create_client_from(config): - username = config.get(CONF_USERNAME) - password = config.get(CONF_PASSWORD) - id = config.get(CONF_ID) - enable_simulator = config.get(CONF_SIMULATOR) - enable_temperature_client = config.get(CONF_ENABLE_TEMPERATURE_CLIENT) - entity_id = config.get(CONF_ENTITY_ID) - host = config.get(CONF_HOST) - access_token = config.get(CONF_ACCESS_TOKEN) - - if enable_simulator: - _LOGGER.info('Registering Salus Thermostat client simulator...') - - return Client(simulator.WebClient(), simulator.TemperatureClient()) - - web_client = WebClient(username, password, id) - - if not enable_temperature_client: - _LOGGER.info('Registering Salus Thermostat client...') - - return web_client - - _LOGGER.info('Registering Salus Thermostat client with Temperature client...') - - ha_client = HaTemperatureClient(host, entity_id, access_token) - return Client(web_client, ha_client) +""" +Adds support for the Salus Thermostat units. +""" +import logging + +import homeassistant.helpers.config_validation as cv +import voluptuous as vol + +from homeassistant.const import ( + CONF_PASSWORD, + CONF_USERNAME, + CONF_ID, + CONF_ENTITY_ID, + CONF_ACCESS_TOKEN, + CONF_HOST +) + +CONF_SIMULATOR = 'simulator' +CONF_ENABLE_TEMPERATURE_CLIENT = 'enable_temperature_client' + +from . import ( ThermostatEntity, Client, WebClient, HaTemperatureClient ) + +from . import simulator + +from homeassistant.components.climate import PLATFORM_SCHEMA + +from homeassistant.helpers.reload import async_setup_reload_service + +__version__ = "0.3.0" + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_NAME = "Salus Thermostat" + +CONF_NAME = "name" + +DOMAIN = "salusfy" +PLATFORMS = ["climate"] + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_ID): cv.string, + vol.Optional(CONF_SIMULATOR, default=False): cv.boolean, + vol.Required(CONF_ENTITY_ID): cv.string, + vol.Required(CONF_ACCESS_TOKEN): cv.string, + vol.Optional(CONF_HOST, default='localhost'): cv.string + } +) + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up the E-Thermostat platform.""" + await async_setup_reload_service(hass, DOMAIN, PLATFORMS) + + client = create_client_from(config) + + name = config.get(CONF_NAME) + await async_add_entities( + [ThermostatEntity(name, client)] + ) + + +def create_client_from(config): + username = config.get(CONF_USERNAME) + password = config.get(CONF_PASSWORD) + id = config.get(CONF_ID) + enable_simulator = config.get(CONF_SIMULATOR) + enable_temperature_client = config.get(CONF_ENABLE_TEMPERATURE_CLIENT) + entity_id = config.get(CONF_ENTITY_ID) + host = config.get(CONF_HOST) + access_token = config.get(CONF_ACCESS_TOKEN) + + if enable_simulator: + _LOGGER.info('Registering Salus Thermostat client simulator...') + + return Client(simulator.WebClient(), simulator.TemperatureClient()) + + web_client = WebClient(username, password, id) + + if not enable_temperature_client: + _LOGGER.info('Registering Salus Thermostat client...') + + return web_client + + _LOGGER.info('Registering Salus Thermostat client with Temperature client...') + + ha_client = HaTemperatureClient(host, entity_id, access_token) + return Client(web_client, ha_client) diff --git a/salusfy/ha_temperature_client.py b/salusfy/ha_temperature_client.py index 6ee8ce3..470ae5d 100644 --- a/salusfy/ha_temperature_client.py +++ b/salusfy/ha_temperature_client.py @@ -1,36 +1,36 @@ -from requests import get - -""" -Retrieves the current temperature from -another entity from the Home Assistant API -""" - -class HaTemperatureClient: - def __init__(self, host, entity_id, access_token): - self._entity_id = entity_id - self._host = host - self._access_token = access_token - - - def current_temperature(self): - """Gets the current temperature from HA""" - - url = F"http://{self._host}:8123/api/states/{self._entity_id}" - - headers = { - "Authorization": F"Bearer {self._access_token}", - "Content-Type": "application/json", - } - - response = get(url, headers=headers) - - body = response.json() - - if 'state' not in body: - return None - - state = body['state'] - if state == 'unavailable': - return None - +from requests import get + +""" +Retrieves the current temperature from +another entity from the Home Assistant API +""" + +class HaTemperatureClient: + def __init__(self, host, entity_id, access_token): + self._entity_id = entity_id + self._host = host + self._access_token = access_token + + + def current_temperature(self): + """Gets the current temperature from HA""" + + url = F"http://{self._host}:8123/api/states/{self._entity_id}" + + headers = { + "Authorization": F"Bearer {self._access_token}", + "Content-Type": "application/json", + } + + response = get(url, headers=headers) + + body = response.json() + + if 'state' not in body: + return None + + state = body['state'] + if state == 'unavailable': + return None + return float(state) \ No newline at end of file diff --git a/salusfy/manifest.json b/salusfy/manifest.json index d483edf..c27f279 100644 --- a/salusfy/manifest.json +++ b/salusfy/manifest.json @@ -1,13 +1,13 @@ -{ - "domain": "salusfy", - "name": "Salus Thermostat", - "version": "0.1.0", - "documentation": "https://github.com/floringhimie/salusfy", - "issue_tracker": "https://github.com/floringhimie/salusfy/issues", - "requirements": [], - "dependencies": [], - "codeowners": [ - "@floringhimie" - ], - "iot_class": "cloud_polling" +{ + "domain": "salusfy", + "name": "Salus Thermostat", + "version": "0.1.0", + "documentation": "https://github.com/floringhimie/salusfy", + "issue_tracker": "https://github.com/floringhimie/salusfy/issues", + "requirements": [], + "dependencies": [], + "codeowners": [ + "@floringhimie" + ], + "iot_class": "cloud_polling" } \ No newline at end of file diff --git a/salusfy/simulator/__init__.py b/salusfy/simulator/__init__.py index 983a8d7..c0f344b 100644 --- a/salusfy/simulator/__init__.py +++ b/salusfy/simulator/__init__.py @@ -1,2 +1,2 @@ -from .temperature_client import TemperatureClient +from .temperature_client import TemperatureClient from .web_client import WebClient \ No newline at end of file diff --git a/salusfy/simulator/temperature_client.py b/salusfy/simulator/temperature_client.py index 2e70faa..6cc2f19 100644 --- a/salusfy/simulator/temperature_client.py +++ b/salusfy/simulator/temperature_client.py @@ -1,9 +1,9 @@ -""" -Adds support for simulating the Salus Thermostats. -""" -class TemperatureClient: - def __init__(self): - pass - - def current_temperature(self): +""" +Adds support for simulating the Salus Thermostats. +""" +class TemperatureClient: + def __init__(self): + pass + + def current_temperature(self): return 15.9 \ No newline at end of file diff --git a/salusfy/simulator/web_client.py b/salusfy/simulator/web_client.py index 2281178..ade8a46 100644 --- a/salusfy/simulator/web_client.py +++ b/salusfy/simulator/web_client.py @@ -1,52 +1,52 @@ -""" -Adds support for simulating the Salus Thermostats. -""" -import logging - -from homeassistant.components.climate.const import ( - HVACMode, -) - -from .. import ( - State, - STATE_ON, - STATE_OFF -) - - -_LOGGER = logging.getLogger(__name__) - - -class WebClient: - """Mocks requests to Salus web application""" - - def __init__(self): - """Initialize the client.""" - self._state = State() - self._state.target_temperature = 20.1 - self._state.current_temperature = 15.1 - self._state.frost = 10.1 - - - def set_temperature(self, temperature): - """Set new target temperature.""" - - _LOGGER.info("Setting temperature to %.1f...", temperature) - - self._state.target_temperature = temperature - - - def set_hvac_mode(self, hvac_mode): - """Set HVAC mode, via URL commands.""" - - _LOGGER.info("Setting the HVAC mode to %s...", hvac_mode) - - if hvac_mode == HVACMode.OFF: - self._state.current_operation_mode = STATE_OFF - elif hvac_mode == HVACMode.HEAT: - self._state.current_operation_mode = STATE_ON - - - def get_state(self): - """Retrieves the mock status""" - return self._state +""" +Adds support for simulating the Salus Thermostats. +""" +import logging + +from homeassistant.components.climate.const import ( + HVACMode, +) + +from .. import ( + State, + STATE_ON, + STATE_OFF +) + + +_LOGGER = logging.getLogger(__name__) + + +class WebClient: + """Mocks requests to Salus web application""" + + def __init__(self): + """Initialize the client.""" + self._state = State() + self._state.target_temperature = 20.1 + self._state.current_temperature = 15.1 + self._state.frost = 10.1 + + + def set_temperature(self, temperature): + """Set new target temperature.""" + + _LOGGER.info("Setting temperature to %.1f...", temperature) + + self._state.target_temperature = temperature + + + def set_hvac_mode(self, hvac_mode): + """Set HVAC mode, via URL commands.""" + + _LOGGER.info("Setting the HVAC mode to %s...", hvac_mode) + + if hvac_mode == HVACMode.OFF: + self._state.current_operation_mode = STATE_OFF + elif hvac_mode == HVACMode.HEAT: + self._state.current_operation_mode = STATE_ON + + + def get_state(self): + """Retrieves the mock status""" + return self._state diff --git a/salusfy/state.py b/salusfy/state.py index f484717..641f371 100644 --- a/salusfy/state.py +++ b/salusfy/state.py @@ -1,8 +1,8 @@ -class State: - """The state of the thermostat.""" - def __init__(self): - self.current_temperature = None - self.target_temperature = None - self.frost = None - self.status = None +class State: + """The state of the thermostat.""" + def __init__(self): + self.current_temperature = None + self.target_temperature = None + self.frost = None + self.status = None self.current_operation_mode = None \ No newline at end of file diff --git a/salusfy/thermostat_entity.py b/salusfy/thermostat_entity.py index 3be9921..91ee18f 100644 --- a/salusfy/thermostat_entity.py +++ b/salusfy/thermostat_entity.py @@ -1,152 +1,152 @@ -import logging - -from .web_client import ( - STATE_ON, - STATE_OFF, - MAX_TEMP, - MIN_TEMP -) - -from homeassistant.components.climate.const import ( - HVACAction, - HVACMode, - ClimateEntityFeature, - SUPPORT_PRESET_MODE -) - -from homeassistant.const import ( - ATTR_TEMPERATURE, - UnitOfTemperature, -) - -try: - from homeassistant.components.climate import ClimateEntity -except ImportError: - from homeassistant.components.climate import ClimateDevice as ClimateEntity - -SUPPORT_FLAGS = ClimateEntityFeature.TARGET_TEMPERATURE - -class ThermostatEntity(ClimateEntity): - """Representation of a Salus Thermostat device.""" - - def __init__(self, name, client): - """Initialize the thermostat.""" - self._name = name - self._client = client - self._state = None - - self.update() - - @property - def supported_features(self): - """Return the list of supported features.""" - return SUPPORT_FLAGS - - @property - def name(self): - """Return the name of the thermostat.""" - return self._name - - @property - def unique_id(self) -> str: - """Return the unique ID for this thermostat.""" - return "_".join([self._name, "climate"]) - - @property - def should_poll(self): - """Return if polling is required.""" - return True - - @property - def min_temp(self): - """Return the minimum temperature.""" - return MIN_TEMP - - @property - def max_temp(self): - """Return the maximum temperature.""" - return MAX_TEMP - - @property - def temperature_unit(self): - """Return the unit of measurement.""" - return UnitOfTemperature.CELSIUS - - @property - def current_temperature(self): - """Return the current temperature.""" - return self._state.current_temperature - - @property - def target_temperature(self): - """Return the temperature we try to reach.""" - return self._state.target_temperature - - - @property - def hvac_mode(self): - """Return hvac operation ie. heat, cool mode.""" - try: - climate_mode = self._state.current_operation_mode - curr_hvac_mode = HVACMode.OFF - if climate_mode == STATE_ON: - curr_hvac_mode = HVACMode.HEAT - else: - curr_hvac_mode = HVACMode.OFF - except KeyError: - return HVACMode.OFF - return curr_hvac_mode - - @property - def hvac_modes(self): - """HVAC modes.""" - return [HVACMode.HEAT, HVACMode.OFF] - - @property - def hvac_action(self): - """Return the current running hvac operation.""" - if self._state.status == STATE_ON: - return HVACAction.HEATING - return HVACAction.IDLE - - - @property - def preset_mode(self): - """Return the current preset mode, e.g., home, away, temp.""" - return self._state.status - - @property - def preset_modes(self): - """Return a list of available preset modes.""" - return SUPPORT_PRESET_MODE - - - def set_temperature(self, **kwargs): - """Set new target temperature.""" - - temperature = kwargs.get(ATTR_TEMPERATURE) - - if temperature is None: - return - - self._client.set_temperature(temperature) - - self._state.target_temperature = temperature - - - def set_hvac_mode(self, hvac_mode): - """Set HVAC mode, via URL commands.""" - - self._client.set_hvac_mode(hvac_mode) - - if hvac_mode == HVACMode.OFF: - self._state.current_operation_mode = STATE_OFF - self._state.status = STATE_OFF - elif hvac_mode == HVACMode.HEAT: - self._state.current_operation_mode = STATE_ON - self._state.status = STATE_ON - - - def update(self): - """Get the latest state data.""" +import logging + +from .web_client import ( + STATE_ON, + STATE_OFF, + MAX_TEMP, + MIN_TEMP +) + +from homeassistant.components.climate.const import ( + HVACAction, + HVACMode, + ClimateEntityFeature, + SUPPORT_PRESET_MODE +) + +from homeassistant.const import ( + ATTR_TEMPERATURE, + UnitOfTemperature, +) + +try: + from homeassistant.components.climate import ClimateEntity +except ImportError: + from homeassistant.components.climate import ClimateDevice as ClimateEntity + +SUPPORT_FLAGS = ClimateEntityFeature.TARGET_TEMPERATURE + +class ThermostatEntity(ClimateEntity): + """Representation of a Salus Thermostat device.""" + + def __init__(self, name, client): + """Initialize the thermostat.""" + self._name = name + self._client = client + self._state = None + + self.update() + + @property + def supported_features(self): + """Return the list of supported features.""" + return SUPPORT_FLAGS + + @property + def name(self): + """Return the name of the thermostat.""" + return self._name + + @property + def unique_id(self) -> str: + """Return the unique ID for this thermostat.""" + return "_".join([self._name, "climate"]) + + @property + def should_poll(self): + """Return if polling is required.""" + return True + + @property + def min_temp(self): + """Return the minimum temperature.""" + return MIN_TEMP + + @property + def max_temp(self): + """Return the maximum temperature.""" + return MAX_TEMP + + @property + def temperature_unit(self): + """Return the unit of measurement.""" + return UnitOfTemperature.CELSIUS + + @property + def current_temperature(self): + """Return the current temperature.""" + return self._state.current_temperature + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + return self._state.target_temperature + + + @property + def hvac_mode(self): + """Return hvac operation ie. heat, cool mode.""" + try: + climate_mode = self._state.current_operation_mode + curr_hvac_mode = HVACMode.OFF + if climate_mode == STATE_ON: + curr_hvac_mode = HVACMode.HEAT + else: + curr_hvac_mode = HVACMode.OFF + except KeyError: + return HVACMode.OFF + return curr_hvac_mode + + @property + def hvac_modes(self): + """HVAC modes.""" + return [HVACMode.HEAT, HVACMode.OFF] + + @property + def hvac_action(self): + """Return the current running hvac operation.""" + if self._state.status == STATE_ON: + return HVACAction.HEATING + return HVACAction.IDLE + + + @property + def preset_mode(self): + """Return the current preset mode, e.g., home, away, temp.""" + return self._state.status + + @property + def preset_modes(self): + """Return a list of available preset modes.""" + return SUPPORT_PRESET_MODE + + + def set_temperature(self, **kwargs): + """Set new target temperature.""" + + temperature = kwargs.get(ATTR_TEMPERATURE) + + if temperature is None: + return + + self._client.set_temperature(temperature) + + self._state.target_temperature = temperature + + + def set_hvac_mode(self, hvac_mode): + """Set HVAC mode, via URL commands.""" + + self._client.set_hvac_mode(hvac_mode) + + if hvac_mode == HVACMode.OFF: + self._state.current_operation_mode = STATE_OFF + self._state.status = STATE_OFF + elif hvac_mode == HVACMode.HEAT: + self._state.current_operation_mode = STATE_ON + self._state.status = STATE_ON + + + def update(self): + """Get the latest state data.""" self._state = self._client.get_state() \ No newline at end of file diff --git a/salusfy/web_client.py b/salusfy/web_client.py index e0b0742..99aec23 100644 --- a/salusfy/web_client.py +++ b/salusfy/web_client.py @@ -1,160 +1,160 @@ -""" -Adds support for the Salus Thermostat units. -""" -import time -import logging -import re -import requests -import json - -from .state import State - -HVAC_MODE_HEAT = "heat" -HVAC_MODE_OFF = "off" - -STATE_ON = "ON" -STATE_OFF = "OFF" - -_LOGGER = logging.getLogger(__name__) - -URL_LOGIN = "https://salus-it500.com/public/login.php" -URL_GET_TOKEN = "https://salus-it500.com/public/control.php" -URL_GET_DATA = "https://salus-it500.com/public/ajax_device_values.php" -URL_SET_DATA = "https://salus-it500.com/includes/set.php" - -# Values from web interface -MIN_TEMP = 5 -MAX_TEMP = 34.5 -MAX_TOKEN_AGE_SECONDS = 60 * 10 - -class WebClient: - """Adapter around Salus IT500 web application.""" - - def __init__(self, username, password, id): - """Initialize the client.""" - self._username = username - self._password = password - self._id = id - self._token = None - self._tokenRetrievedAt = None - - self._session = requests.Session() - - - def set_temperature(self, temperature): - """Set new target temperature, via URL commands.""" - - _LOGGER.info("Setting the temperature to %.1f...", temperature) - - token = self.obtain_token() - - payload = {"token": token, "devId": self._id, "tempUnit": "0", "current_tempZ1_set": "1", "current_tempZ1": temperature} - headers = {"Content-Type": "application/x-www-form-urlencoded"} - - try: - self._session.post(URL_SET_DATA, data=payload, headers=headers) - _LOGGER.info("Salusfy set_temperature: OK") - except: - _LOGGER.error("Error Setting the temperature.") - - - def set_hvac_mode(self, hvac_mode): - """Set HVAC mode, via URL commands.""" - - _LOGGER.info("Setting the HVAC mode to %s...", hvac_mode) - - headers = {"Content-Type": "application/x-www-form-urlencoded"} - - auto = "1" - if hvac_mode == HVAC_MODE_OFF: - auto = "1" - elif hvac_mode == HVAC_MODE_HEAT: - auto = "0" - - token = self.obtain_token() - - payload = {"token": token, "devId": self._id, "auto": auto, "auto_setZ1": "1"} - try: - self._session.post(URL_SET_DATA, data=payload, headers=headers) - except: - _LOGGER.error("Error Setting HVAC mode to %s", hvac_mode) - - - def obtain_token(self): - """Gets the existing session token of the thermostat or retrieves a new one if expired.""" - - if self._token is None: - _LOGGER.info("Retrieving token for the first time this session...") - self.get_token() - return self._token - - if self._tokenRetrievedAt > time.time() - MAX_TOKEN_AGE_SECONDS: - _LOGGER.info("Using cached token...") - return self._token - - _LOGGER.info("Token has expired, getting new one...") - self.get_token() - return self._token - - - def get_token(self): - """Get the Session Token of the Thermostat.""" - - _LOGGER.info("Getting token from Salus...") - - payload = {"IDemail": self._username, "password": self._password, "login": "Login", "keep_logged_in": "1"} - headers = {"Content-Type": "application/x-www-form-urlencoded"} - - try: - self._session.post(URL_LOGIN, data=payload, headers=headers) - params = {"devId": self._id} - getTkoken = self._session.get(URL_GET_TOKEN, params=params) - result = re.search('', getTkoken.text) - _LOGGER.info("Salusfy get_token OK") - self._token = result.group(1) - self._tokenRetrievedAt = time.time() - except: - self._token = None - self._tokenRetrievedAt = None - _LOGGER.error("Error getting the session token.") - - - def get_state(self): - """Retrieve the current state from the Salus gateway""" - - _LOGGER.info("Retrieving current state from Salus Gateway...") - - token = self.obtain_token() - - params = {"devId": self._id, "token": token, "&_": str(int(round(time.time() * 1000)))} - try: - r = self._session.get(url = URL_GET_DATA, params = params) - if not r: - _LOGGER.error("Could not get data from Salus.") - return None - except: - _LOGGER.error("Error Getting the data from Web. Please check the connection to salus-it500.com manually.") - return None - - data = json.loads(r.text) - _LOGGER.info("Salusfy get_data output " + r.text) - - state = State() - state.target_temperature = float(data["CH1currentSetPoint"]) - state.current_temperature = float(data["CH1currentRoomTemp"]) - state.frost = float(data["frost"]) - - status = data['CH1heatOnOffStatus'] - if status == "1": - state.status = STATE_ON - else: - state.status = STATE_OFF - - mode = data['CH1heatOnOff'] - if mode == "1": - state.current_operation_mode = STATE_OFF - else: - state.current_operation_mode = STATE_ON - - return state - +""" +Adds support for the Salus Thermostat units. +""" +import time +import logging +import re +import requests +import json + +from .state import State + +HVAC_MODE_HEAT = "heat" +HVAC_MODE_OFF = "off" + +STATE_ON = "ON" +STATE_OFF = "OFF" + +_LOGGER = logging.getLogger(__name__) + +URL_LOGIN = "https://salus-it500.com/public/login.php" +URL_GET_TOKEN = "https://salus-it500.com/public/control.php" +URL_GET_DATA = "https://salus-it500.com/public/ajax_device_values.php" +URL_SET_DATA = "https://salus-it500.com/includes/set.php" + +# Values from web interface +MIN_TEMP = 5 +MAX_TEMP = 34.5 +MAX_TOKEN_AGE_SECONDS = 60 * 10 + +class WebClient: + """Adapter around Salus IT500 web application.""" + + def __init__(self, username, password, id): + """Initialize the client.""" + self._username = username + self._password = password + self._id = id + self._token = None + self._tokenRetrievedAt = None + + self._session = requests.Session() + + + def set_temperature(self, temperature): + """Set new target temperature, via URL commands.""" + + _LOGGER.info("Setting the temperature to %.1f...", temperature) + + token = self.obtain_token() + + payload = {"token": token, "devId": self._id, "tempUnit": "0", "current_tempZ1_set": "1", "current_tempZ1": temperature} + headers = {"Content-Type": "application/x-www-form-urlencoded"} + + try: + self._session.post(URL_SET_DATA, data=payload, headers=headers) + _LOGGER.info("Salusfy set_temperature: OK") + except: + _LOGGER.error("Error Setting the temperature.") + + + def set_hvac_mode(self, hvac_mode): + """Set HVAC mode, via URL commands.""" + + _LOGGER.info("Setting the HVAC mode to %s...", hvac_mode) + + headers = {"Content-Type": "application/x-www-form-urlencoded"} + + auto = "1" + if hvac_mode == HVAC_MODE_OFF: + auto = "1" + elif hvac_mode == HVAC_MODE_HEAT: + auto = "0" + + token = self.obtain_token() + + payload = {"token": token, "devId": self._id, "auto": auto, "auto_setZ1": "1"} + try: + self._session.post(URL_SET_DATA, data=payload, headers=headers) + except: + _LOGGER.error("Error Setting HVAC mode to %s", hvac_mode) + + + def obtain_token(self): + """Gets the existing session token of the thermostat or retrieves a new one if expired.""" + + if self._token is None: + _LOGGER.info("Retrieving token for the first time this session...") + self.get_token() + return self._token + + if self._tokenRetrievedAt > time.time() - MAX_TOKEN_AGE_SECONDS: + _LOGGER.info("Using cached token...") + return self._token + + _LOGGER.info("Token has expired, getting new one...") + self.get_token() + return self._token + + + def get_token(self): + """Get the Session Token of the Thermostat.""" + + _LOGGER.info("Getting token from Salus...") + + payload = {"IDemail": self._username, "password": self._password, "login": "Login", "keep_logged_in": "1"} + headers = {"Content-Type": "application/x-www-form-urlencoded"} + + try: + self._session.post(URL_LOGIN, data=payload, headers=headers) + params = {"devId": self._id} + getTkoken = self._session.get(URL_GET_TOKEN, params=params) + result = re.search('', getTkoken.text) + _LOGGER.info("Salusfy get_token OK") + self._token = result.group(1) + self._tokenRetrievedAt = time.time() + except: + self._token = None + self._tokenRetrievedAt = None + _LOGGER.error("Error getting the session token.") + + + def get_state(self): + """Retrieve the current state from the Salus gateway""" + + _LOGGER.info("Retrieving current state from Salus Gateway...") + + token = self.obtain_token() + + params = {"devId": self._id, "token": token, "&_": str(int(round(time.time() * 1000)))} + try: + r = self._session.get(url = URL_GET_DATA, params = params) + if not r: + _LOGGER.error("Could not get data from Salus.") + return None + except: + _LOGGER.error("Error Getting the data from Web. Please check the connection to salus-it500.com manually.") + return None + + data = json.loads(r.text) + _LOGGER.info("Salusfy get_data output " + r.text) + + state = State() + state.target_temperature = float(data["CH1currentSetPoint"]) + state.current_temperature = float(data["CH1currentRoomTemp"]) + state.frost = float(data["frost"]) + + status = data['CH1heatOnOffStatus'] + if status == "1": + state.status = STATE_ON + else: + state.status = STATE_OFF + + mode = data['CH1heatOnOff'] + if mode == "1": + state.current_operation_mode = STATE_OFF + else: + state.current_operation_mode = STATE_ON + + return state + diff --git a/test.ps1 b/test.ps1 index 0d77805..d20057f 100644 --- a/test.ps1 +++ b/test.ps1 @@ -1,5 +1,5 @@ -# use this to run test suite on windows hosts - -docker build -t salusfy . - +# use this to run test suite on windows hosts + +docker build -t salusfy . + docker run -it -v .:/app salusfy \ No newline at end of file diff --git a/tests/config_adapter.py b/tests/config_adapter.py index f3f40bd..f10d344 100644 --- a/tests/config_adapter.py +++ b/tests/config_adapter.py @@ -1,32 +1,32 @@ -class ConfigAdapter: - def __init__(self, config): - self._config = config - - - def get(self, key): - if (key == 'name'): - return 'Simulator' - - if (key == 'id'): - return self._config.DEVICE_ID - - if (key == 'username'): - return self._config.USERNAME - - if (key == 'password'): - return self._config.PASSWORD - - if (key == 'simulator'): - return self._config.SIMULATOR - - if (key == 'enable_temperature_client'): - return self._config.ENABLE_TEMPERATURE_CLIENT - - if (key == 'host'): - return self._config.HOST - - if (key == 'entity_id'): - return self._config.ENTITY_ID - - if (key == 'access_token'): +class ConfigAdapter: + def __init__(self, config): + self._config = config + + + def get(self, key): + if (key == 'name'): + return 'Simulator' + + if (key == 'id'): + return self._config.DEVICE_ID + + if (key == 'username'): + return self._config.USERNAME + + if (key == 'password'): + return self._config.PASSWORD + + if (key == 'simulator'): + return self._config.SIMULATOR + + if (key == 'enable_temperature_client'): + return self._config.ENABLE_TEMPERATURE_CLIENT + + if (key == 'host'): + return self._config.HOST + + if (key == 'entity_id'): + return self._config.ENTITY_ID + + if (key == 'access_token'): return self._config.ACCESS_TOKEN \ No newline at end of file diff --git a/tests/entity_registry.py b/tests/entity_registry.py index 43d153d..7b7af3e 100644 --- a/tests/entity_registry.py +++ b/tests/entity_registry.py @@ -1,14 +1,14 @@ -class EntityRegistry: - def __init__(self): - self._entities = [] - - async def register(self, list): - self._entities.extend(list) - - @property - def entities(self): - return self._entities - - @property - def first(self): +class EntityRegistry: + def __init__(self): + self._entities = [] + + async def register(self, list): + self._entities.extend(list) + + @property + def entities(self): + return self._entities + + @property + def first(self): return self._entities[0] \ No newline at end of file diff --git a/tests/mock_config.py b/tests/mock_config.py index 5af154f..0125a04 100644 --- a/tests/mock_config.py +++ b/tests/mock_config.py @@ -1,8 +1,8 @@ -USERNAME = "john@smith.com" -PASSWORD = "12345" -DEVICE_ID = "999999" -ENTITY_ID = "sensor.everything_presence_one_temperature" -ACCESS_TOKEN = "some-secret" -SIMULATOR = True -ENABLE_TEMPERATURE_CLIENT = True +USERNAME = "john@smith.com" +PASSWORD = "12345" +DEVICE_ID = "999999" +ENTITY_ID = "sensor.everything_presence_one_temperature" +ACCESS_TOKEN = "some-secret" +SIMULATOR = True +ENABLE_TEMPERATURE_CLIENT = True HOST = "192.168.0.99" \ No newline at end of file diff --git a/tests/test_client.py b/tests/test_client.py index c044a17..5c728ef 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,73 +1,73 @@ -import pytest -from unittest.mock import MagicMock - -from salusfy import ( Client, State ) -from homeassistant.components.climate.const import ( - HVAC_MODE_HEAT -) - -@pytest.fixture -def mock_client(): - mock = MagicMock() - - state = State() - state.current_temperature = 15.3 - state.target_temperature = 33.3 - mock.configure_mock( - **{ - "get_state.return_value": state - } - ) - - return mock - - -@pytest.fixture -def mock_ha_client(): - mock = MagicMock() - - mock.configure_mock( - **{ - "current_temperature.return_value": 21.1 - } - ) - - return mock - - -def test_entity_returns_target_temp_from_web_client(mock_client, mock_ha_client): - target = Client(mock_client, mock_ha_client) - - assert target.get_state().target_temperature == 33.3 - - -def test_entity_returns_target_temp_from_home_assistant_client(mock_client, mock_ha_client): - target = Client(mock_client, mock_ha_client) - - assert target.get_state().current_temperature == 21.1 - - -def test_entity_call_salus_client_only_once(mock_client, mock_ha_client): - target = Client(mock_client, mock_ha_client) - - target.get_state() - target.get_state() - - mock_client.get_state.assert_called_once() - assert target.get_state().target_temperature == 33.3 - - -def test_entity_delegates_set_temperature_salus_client(mock_client, mock_ha_client): - target = Client(mock_client, mock_ha_client) - - target.set_temperature(temperature=29.9) - - mock_client.set_temperature.assert_called_once_with(29.9) - - -def test_entity_delegates_set_hvac_mode_to_salus_client(mock_client, mock_ha_client): - target = Client(mock_client, mock_ha_client) - - target.set_hvac_mode(hvac_mode=HVAC_MODE_HEAT) - +import pytest +from unittest.mock import MagicMock + +from salusfy import ( Client, State ) +from homeassistant.components.climate.const import ( + HVAC_MODE_HEAT +) + +@pytest.fixture +def mock_client(): + mock = MagicMock() + + state = State() + state.current_temperature = 15.3 + state.target_temperature = 33.3 + mock.configure_mock( + **{ + "get_state.return_value": state + } + ) + + return mock + + +@pytest.fixture +def mock_ha_client(): + mock = MagicMock() + + mock.configure_mock( + **{ + "current_temperature.return_value": 21.1 + } + ) + + return mock + + +def test_entity_returns_target_temp_from_web_client(mock_client, mock_ha_client): + target = Client(mock_client, mock_ha_client) + + assert target.get_state().target_temperature == 33.3 + + +def test_entity_returns_target_temp_from_home_assistant_client(mock_client, mock_ha_client): + target = Client(mock_client, mock_ha_client) + + assert target.get_state().current_temperature == 21.1 + + +def test_entity_call_salus_client_only_once(mock_client, mock_ha_client): + target = Client(mock_client, mock_ha_client) + + target.get_state() + target.get_state() + + mock_client.get_state.assert_called_once() + assert target.get_state().target_temperature == 33.3 + + +def test_entity_delegates_set_temperature_salus_client(mock_client, mock_ha_client): + target = Client(mock_client, mock_ha_client) + + target.set_temperature(temperature=29.9) + + mock_client.set_temperature.assert_called_once_with(29.9) + + +def test_entity_delegates_set_hvac_mode_to_salus_client(mock_client, mock_ha_client): + target = Client(mock_client, mock_ha_client) + + target.set_hvac_mode(hvac_mode=HVAC_MODE_HEAT) + mock_client.set_hvac_mode.assert_called_once_with(HVAC_MODE_HEAT) \ No newline at end of file diff --git a/tests/test_climate.py b/tests/test_climate.py index ed4e754..175a87d 100644 --- a/tests/test_climate.py +++ b/tests/test_climate.py @@ -1,51 +1,51 @@ -import pytest - -from salusfy import climate -from .config_adapter import ConfigAdapter -from .entity_registry import EntityRegistry - -from . import mock_config - -class MockHass: - @property - def services(self): - return self - - def has_service(self, domain, service): - return False - - def async_register(self, domain, service, admin_handler, schema): - pass - - -@pytest.mark.asyncio -async def setup_climate_platform(): - registry = EntityRegistry() - config_adapter = ConfigAdapter(mock_config) - await climate.async_setup_platform(MockHass(), config_adapter, async_add_entities=registry.register, discovery_info=None) - return registry - - -@pytest.mark.asyncio -async def test_entity_is_registered(): - registry = await setup_climate_platform() - - assert len(registry.entities) == 1 - - -@pytest.mark.asyncio -async def test_entity_returns_mock_temperature(): - registry = await setup_climate_platform() - - thermostat = registry.first - - assert thermostat.current_temperature == 15.9 - - -@pytest.mark.asyncio -async def test_entity_returns_mock_target_temperature(): - registry = await setup_climate_platform() - - thermostat = registry.first - +import pytest + +from salusfy import climate +from .config_adapter import ConfigAdapter +from .entity_registry import EntityRegistry + +from . import mock_config + +class MockHass: + @property + def services(self): + return self + + def has_service(self, domain, service): + return False + + def async_register(self, domain, service, admin_handler, schema): + pass + + +@pytest.mark.asyncio +async def setup_climate_platform(): + registry = EntityRegistry() + config_adapter = ConfigAdapter(mock_config) + await climate.async_setup_platform(MockHass(), config_adapter, async_add_entities=registry.register, discovery_info=None) + return registry + + +@pytest.mark.asyncio +async def test_entity_is_registered(): + registry = await setup_climate_platform() + + assert len(registry.entities) == 1 + + +@pytest.mark.asyncio +async def test_entity_returns_mock_temperature(): + registry = await setup_climate_platform() + + thermostat = registry.first + + assert thermostat.current_temperature == 15.9 + + +@pytest.mark.asyncio +async def test_entity_returns_mock_target_temperature(): + registry = await setup_climate_platform() + + thermostat = registry.first + assert thermostat.target_temperature == 20.1 \ No newline at end of file diff --git a/tests/test_thermostat_entity.py b/tests/test_thermostat_entity.py index 7f41380..a047e82 100644 --- a/tests/test_thermostat_entity.py +++ b/tests/test_thermostat_entity.py @@ -1,46 +1,46 @@ -import pytest -from unittest.mock import MagicMock - -from salusfy import ( ThermostatEntity, State ) -from homeassistant.components.climate.const import ( - HVAC_MODE_HEAT -) - -@pytest.fixture -def mock_client(): - mock = MagicMock() - - state = State() - state.current_temperature = 15.3 - state.target_temperature = 33.3 - mock.configure_mock( - **{ - "get_state.return_value": state - } - ) - - return mock - - -def test_entity_returns_target_temp_from_web_client(mock_client): - target = ThermostatEntity('mock', mock_client) - - assert target.target_temperature == 33.3 - - -def test_entity_delegates_set_temperature_web_client(mock_client): - target = ThermostatEntity('mock', mock_client) - - target.set_temperature(temperature=29.9) - - mock_client.set_temperature.assert_called_once_with(29.9) - assert target.target_temperature == 29.9 - - -def test_entity_delegates_set_hvac_mode_to_web_client(mock_client): - target = ThermostatEntity('mock', mock_client) - - target.set_hvac_mode(hvac_mode=HVAC_MODE_HEAT) - - mock_client.set_hvac_mode.assert_called_once_with(HVAC_MODE_HEAT) +import pytest +from unittest.mock import MagicMock + +from salusfy import ( ThermostatEntity, State ) +from homeassistant.components.climate.const import ( + HVAC_MODE_HEAT +) + +@pytest.fixture +def mock_client(): + mock = MagicMock() + + state = State() + state.current_temperature = 15.3 + state.target_temperature = 33.3 + mock.configure_mock( + **{ + "get_state.return_value": state + } + ) + + return mock + + +def test_entity_returns_target_temp_from_web_client(mock_client): + target = ThermostatEntity('mock', mock_client) + + assert target.target_temperature == 33.3 + + +def test_entity_delegates_set_temperature_web_client(mock_client): + target = ThermostatEntity('mock', mock_client) + + target.set_temperature(temperature=29.9) + + mock_client.set_temperature.assert_called_once_with(29.9) + assert target.target_temperature == 29.9 + + +def test_entity_delegates_set_hvac_mode_to_web_client(mock_client): + target = ThermostatEntity('mock', mock_client) + + target.set_hvac_mode(hvac_mode=HVAC_MODE_HEAT) + + mock_client.set_hvac_mode.assert_called_once_with(HVAC_MODE_HEAT) assert target.hvac_mode == HVAC_MODE_HEAT \ No newline at end of file From 1caff2c028e4c98472181a84bbf5ff18a98e62db Mon Sep 17 00:00:00 2001 From: Matt Turner Date: Sat, 17 Feb 2024 10:48:34 +0000 Subject: [PATCH 33/55] Remove socket blocking (#8) --- .github/workflows/ci.yml | 6 +++--- Dockerfile | 12 ------------ requirements.ci.txt | 4 ++++ requirements.test.txt | 2 +- requirements.txt | 2 +- 5 files changed, 9 insertions(+), 17 deletions(-) delete mode 100644 Dockerfile create mode 100644 requirements.ci.txt diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cc2a3e7..99da10e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,9 +15,9 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python 3.10 - uses: actions/setup-python@v3 + uses: actions/setup-python@v5 with: python-version: "3.10" - name: Install dependencies @@ -25,7 +25,7 @@ jobs: python -m pip install --upgrade pip pip install flake8 pytest if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - if [ -f requirements.test.txt ]; then pip install -r requirements.test.txt; fi + if [ -f requirements.ci.txt ]; then pip install -r requirements.ci.txt; fi - name: Lint with flake8 run: | # stop the build if there are Python syntax errors or undefined names diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 0481401..0000000 --- a/Dockerfile +++ /dev/null @@ -1,12 +0,0 @@ -FROM python:3.9.10 -ENV PIP_DISABLE_ROOT_WARNING=1 -RUN python -m pip install --upgrade pip -COPY requirements.test.txt requirements.test.txt -RUN pip install -r requirements.test.txt -COPY requirements.txt requirements.txt -RUN pip install -r requirements.txt -VOLUME /app -WORKDIR /app -ENV NO_COLOR=yes_please -ENV LANG=C -CMD ["python", "-m", "pytest"] \ No newline at end of file diff --git a/requirements.ci.txt b/requirements.ci.txt new file mode 100644 index 0000000..136b030 --- /dev/null +++ b/requirements.ci.txt @@ -0,0 +1,4 @@ +pytest +pytest-mock +pytest-asyncio +pytest-homeassistant-custom-component \ No newline at end of file diff --git a/requirements.test.txt b/requirements.test.txt index 2698041..82ad30d 100644 --- a/requirements.test.txt +++ b/requirements.test.txt @@ -1,4 +1,4 @@ pytest pytest-mock pytest-asyncio -pytest-homeassistant-custom-component \ No newline at end of file +pytest-homeassistant-custom-component @ git+https://github.com/matthewturner/pytest-homeassistant-custom-component@remove-socket-blocking \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index bebaa03..663bd1f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1 @@ -requests +requests \ No newline at end of file From 593cebd7d7db431332abe5498b66dea6a0981637 Mon Sep 17 00:00:00 2001 From: Matt Turner Date: Sat, 17 Feb 2024 11:12:07 +0000 Subject: [PATCH 34/55] Remove socket blocking (#9) --- config.sample.py | 6 +++++- run.py | 36 ++++++++++++++++++++++-------------- 2 files changed, 27 insertions(+), 15 deletions(-) diff --git a/config.sample.py b/config.sample.py index 61dfa3b..71e735a 100644 --- a/config.sample.py +++ b/config.sample.py @@ -5,4 +5,8 @@ USERNAME = "replace" PASSWORD = "replace" -DEVICE_ID = "replace" \ No newline at end of file +DEVICE_ID = "replace" + +SIMULATOR = True +ENABLE_TEMPERATURE_CLIENT = True +HOST = "192.168.0.9" \ No newline at end of file diff --git a/run.py b/run.py index 3399743..0ab45a0 100644 --- a/run.py +++ b/run.py @@ -3,13 +3,14 @@ # 2 Replace the username/password/deviceid (don't worry, this file will be ignored by git) # 3 Run with `python run.py` +import asyncio from salusfy import climate +from tests.test_climate import MockHass from tests.config_adapter import ConfigAdapter from tests.entity_registry import EntityRegistry from homeassistant.components.climate.const import ( - HVAC_MODE_HEAT, - HVAC_MODE_OFF, + HVACMode ) import config @@ -17,20 +18,27 @@ import logging logging.basicConfig(format='%(levelname)s: %(message)s', level=logging.DEBUG) -registry = EntityRegistry() -config_adapter = ConfigAdapter(config) +async def main(): + registry = EntityRegistry() + config_adapter = ConfigAdapter(config) -climate.setup_platform(None, config_adapter, add_entities=registry.register, discovery_info=None) + await climate.async_setup_platform(MockHass(), config_adapter, async_add_entities=registry.register, discovery_info=None) -thermostat = registry.first + thermostat = registry.first -thermostat.update() -thermostat.update() + thermostat.update() + thermostat.update() -thermostat.set_hvac_mode(HVAC_MODE_HEAT) -thermostat.set_temperature(temperature=9.8) + thermostat.set_hvac_mode(HVACMode.Heat) + thermostat.set_temperature(temperature=9.8) -print("Current: " + str(thermostat.current_temperature)) -print("Target: " + str(thermostat.target_temperature)) -print("HVAC Action: " + thermostat.hvac_action) -print("HVAC Mode: " + thermostat.hvac_mode) \ No newline at end of file + print("Current: " + str(thermostat.current_temperature)) + print("Target: " + str(thermostat.target_temperature)) + print("HVAC Action: " + thermostat.hvac_action) + print("HVAC Mode: " + thermostat.hvac_mode) + + +if __name__ == '__main__': + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + loop.run_until_complete(main()) \ No newline at end of file From d7ddc1b6c3e3052101696b8534788bc2ba8cd4d8 Mon Sep 17 00:00:00 2001 From: Matt Turner Date: Sat, 17 Feb 2024 11:14:23 +0000 Subject: [PATCH 35/55] Fix line endings --- requirements.ci.txt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/requirements.ci.txt b/requirements.ci.txt index 136b030..2698041 100644 --- a/requirements.ci.txt +++ b/requirements.ci.txt @@ -1,4 +1,4 @@ -pytest -pytest-mock -pytest-asyncio +pytest +pytest-mock +pytest-asyncio pytest-homeassistant-custom-component \ No newline at end of file From dbe21e1aa7a77a9391062608f85e08216eff4681 Mon Sep 17 00:00:00 2001 From: Matt Turner Date: Sun, 25 Feb 2024 04:15:41 -0600 Subject: [PATCH 36/55] Switch to async (#10) --- requirements.txt | 3 +- run.py | 8 ++-- salusfy/client.py | 16 +++---- salusfy/ha_temperature_client.py | 6 +-- salusfy/simulator/temperature_client.py | 2 +- salusfy/simulator/web_client.py | 6 +-- salusfy/thermostat_entity.py | 17 ++++--- salusfy/web_client.py | 33 ++++++------- test.ps1 | 5 -- tests/test_client.py | 61 +++++++++++++------------ tests/test_climate.py | 4 ++ tests/test_thermostat_entity.py | 39 +++++++++------- 12 files changed, 103 insertions(+), 97 deletions(-) delete mode 100644 test.ps1 diff --git a/requirements.txt b/requirements.txt index 663bd1f..b1999d3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,2 @@ -requests \ No newline at end of file +requests +requests_async \ No newline at end of file diff --git a/run.py b/run.py index 0ab45a0..2250c7c 100644 --- a/run.py +++ b/run.py @@ -26,11 +26,11 @@ async def main(): thermostat = registry.first - thermostat.update() - thermostat.update() + await thermostat.async_update() + await thermostat.async_update() - thermostat.set_hvac_mode(HVACMode.Heat) - thermostat.set_temperature(temperature=9.8) + await thermostat.set_hvac_mode(HVACMode.Heat) + await thermostat.set_temperature(temperature=9.8) print("Current: " + str(thermostat.current_temperature)) print("Target: " + str(thermostat.target_temperature)) diff --git a/salusfy/client.py b/salusfy/client.py index 5776b55..ef7ac70 100644 --- a/salusfy/client.py +++ b/salusfy/client.py @@ -18,33 +18,31 @@ def __init__(self, web_client : WebClient, temperature_client : HaTemperatureCli self._web_client = web_client self._temperature_client = temperature_client - self.get_state() - - def set_temperature(self, temperature): + async def set_temperature(self, temperature): """Set new target temperature.""" _LOGGER.info("Delegating set_temperature to web client...") - self._web_client.set_temperature(temperature) + await self._web_client.set_temperature(temperature) - def set_hvac_mode(self, hvac_mode): + async def set_hvac_mode(self, hvac_mode): """Set HVAC mode, via URL commands.""" _LOGGER.info("Delegating set_hvac_mode to web client...") - self._web_client.set_hvac_mode(hvac_mode) + await self._web_client.set_hvac_mode(hvac_mode) - def get_state(self): + async def get_state(self): """Retrieves the status""" if self._state is None: _LOGGER.info("Delegating get_state to web client...") - self._state = self._web_client.get_state() + self._state = await self._web_client.get_state() _LOGGER.info("Updating current temperature from temperature client...") - self._state.current_temperature = self._temperature_client.current_temperature() + self._state.current_temperature = await self._temperature_client.current_temperature() return self._state diff --git a/salusfy/ha_temperature_client.py b/salusfy/ha_temperature_client.py index 470ae5d..7c8344a 100644 --- a/salusfy/ha_temperature_client.py +++ b/salusfy/ha_temperature_client.py @@ -1,4 +1,4 @@ -from requests import get +from requests_async import get """ Retrieves the current temperature from @@ -12,7 +12,7 @@ def __init__(self, host, entity_id, access_token): self._access_token = access_token - def current_temperature(self): + async def current_temperature(self): """Gets the current temperature from HA""" url = F"http://{self._host}:8123/api/states/{self._entity_id}" @@ -22,7 +22,7 @@ def current_temperature(self): "Content-Type": "application/json", } - response = get(url, headers=headers) + response = await get(url, headers=headers) body = response.json() diff --git a/salusfy/simulator/temperature_client.py b/salusfy/simulator/temperature_client.py index 6cc2f19..157fd6d 100644 --- a/salusfy/simulator/temperature_client.py +++ b/salusfy/simulator/temperature_client.py @@ -5,5 +5,5 @@ class TemperatureClient: def __init__(self): pass - def current_temperature(self): + async def current_temperature(self): return 15.9 \ No newline at end of file diff --git a/salusfy/simulator/web_client.py b/salusfy/simulator/web_client.py index ade8a46..3e18b83 100644 --- a/salusfy/simulator/web_client.py +++ b/salusfy/simulator/web_client.py @@ -28,7 +28,7 @@ def __init__(self): self._state.frost = 10.1 - def set_temperature(self, temperature): + async def set_temperature(self, temperature): """Set new target temperature.""" _LOGGER.info("Setting temperature to %.1f...", temperature) @@ -36,7 +36,7 @@ def set_temperature(self, temperature): self._state.target_temperature = temperature - def set_hvac_mode(self, hvac_mode): + async def set_hvac_mode(self, hvac_mode): """Set HVAC mode, via URL commands.""" _LOGGER.info("Setting the HVAC mode to %s...", hvac_mode) @@ -47,6 +47,6 @@ def set_hvac_mode(self, hvac_mode): self._state.current_operation_mode = STATE_ON - def get_state(self): + async def get_state(self): """Retrieves the mock status""" return self._state diff --git a/salusfy/thermostat_entity.py b/salusfy/thermostat_entity.py index 91ee18f..f7d09bd 100644 --- a/salusfy/thermostat_entity.py +++ b/salusfy/thermostat_entity.py @@ -34,8 +34,7 @@ def __init__(self, name, client): self._name = name self._client = client self._state = None - - self.update() + @property def supported_features(self): @@ -121,7 +120,7 @@ def preset_modes(self): return SUPPORT_PRESET_MODE - def set_temperature(self, **kwargs): + async def set_temperature(self, **kwargs): """Set new target temperature.""" temperature = kwargs.get(ATTR_TEMPERATURE) @@ -129,15 +128,15 @@ def set_temperature(self, **kwargs): if temperature is None: return - self._client.set_temperature(temperature) + await self._client.set_temperature(temperature) self._state.target_temperature = temperature - def set_hvac_mode(self, hvac_mode): + async def set_hvac_mode(self, hvac_mode): """Set HVAC mode, via URL commands.""" - self._client.set_hvac_mode(hvac_mode) + await self._client.set_hvac_mode(hvac_mode) if hvac_mode == HVACMode.OFF: self._state.current_operation_mode = STATE_OFF @@ -147,6 +146,6 @@ def set_hvac_mode(self, hvac_mode): self._state.status = STATE_ON - def update(self): - """Get the latest state data.""" - self._state = self._client.get_state() \ No newline at end of file + async def async_update(self): + """Retrieve latest state data.""" + self._state = await self._client.get_state() \ No newline at end of file diff --git a/salusfy/web_client.py b/salusfy/web_client.py index 99aec23..c36595f 100644 --- a/salusfy/web_client.py +++ b/salusfy/web_client.py @@ -4,8 +4,8 @@ import time import logging import re -import requests -import json +import requests_async as requests +import json from .state import State @@ -41,12 +41,12 @@ def __init__(self, username, password, id): self._session = requests.Session() - def set_temperature(self, temperature): + async def set_temperature(self, temperature): """Set new target temperature, via URL commands.""" _LOGGER.info("Setting the temperature to %.1f...", temperature) - token = self.obtain_token() + token = await self.obtain_token() payload = {"token": token, "devId": self._id, "tempUnit": "0", "current_tempZ1_set": "1", "current_tempZ1": temperature} headers = {"Content-Type": "application/x-www-form-urlencoded"} @@ -58,7 +58,7 @@ def set_temperature(self, temperature): _LOGGER.error("Error Setting the temperature.") - def set_hvac_mode(self, hvac_mode): + async def set_hvac_mode(self, hvac_mode): """Set HVAC mode, via URL commands.""" _LOGGER.info("Setting the HVAC mode to %s...", hvac_mode) @@ -71,7 +71,7 @@ def set_hvac_mode(self, hvac_mode): elif hvac_mode == HVAC_MODE_HEAT: auto = "0" - token = self.obtain_token() + token = await self.obtain_token() payload = {"token": token, "devId": self._id, "auto": auto, "auto_setZ1": "1"} try: @@ -80,12 +80,12 @@ def set_hvac_mode(self, hvac_mode): _LOGGER.error("Error Setting HVAC mode to %s", hvac_mode) - def obtain_token(self): + async def obtain_token(self): """Gets the existing session token of the thermostat or retrieves a new one if expired.""" if self._token is None: _LOGGER.info("Retrieving token for the first time this session...") - self.get_token() + await self.get_token() return self._token if self._tokenRetrievedAt > time.time() - MAX_TOKEN_AGE_SECONDS: @@ -93,11 +93,11 @@ def obtain_token(self): return self._token _LOGGER.info("Token has expired, getting new one...") - self.get_token() + await self.get_token() return self._token - def get_token(self): + async def get_token(self): """Get the Session Token of the Thermostat.""" _LOGGER.info("Getting token from Salus...") @@ -106,29 +106,30 @@ def get_token(self): headers = {"Content-Type": "application/x-www-form-urlencoded"} try: - self._session.post(URL_LOGIN, data=payload, headers=headers) + await self._session.post(URL_LOGIN, data=payload, headers=headers, verify=False) params = {"devId": self._id} - getTkoken = self._session.get(URL_GET_TOKEN, params=params) + getTkoken = await self._session.get(URL_GET_TOKEN, params=params) result = re.search('', getTkoken.text) _LOGGER.info("Salusfy get_token OK") self._token = result.group(1) self._tokenRetrievedAt = time.time() - except: + except Exception as e: self._token = None self._tokenRetrievedAt = None _LOGGER.error("Error getting the session token.") + _LOGGER.error(e) - def get_state(self): + async def get_state(self): """Retrieve the current state from the Salus gateway""" _LOGGER.info("Retrieving current state from Salus Gateway...") - token = self.obtain_token() + token = await self.obtain_token() params = {"devId": self._id, "token": token, "&_": str(int(round(time.time() * 1000)))} try: - r = self._session.get(url = URL_GET_DATA, params = params) + r = await self._session.get(url = URL_GET_DATA, params = params) if not r: _LOGGER.error("Could not get data from Salus.") return None diff --git a/test.ps1 b/test.ps1 deleted file mode 100644 index d20057f..0000000 --- a/test.ps1 +++ /dev/null @@ -1,5 +0,0 @@ -# use this to run test suite on windows hosts - -docker build -t salusfy . - -docker run -it -v .:/app salusfy \ No newline at end of file diff --git a/tests/test_client.py b/tests/test_client.py index 5c728ef..ed74847 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,73 +1,76 @@ import pytest -from unittest.mock import MagicMock +from unittest.mock import Mock -from salusfy import ( Client, State ) +from salusfy import ( Client, State, WebClient, HaTemperatureClient ) from homeassistant.components.climate.const import ( - HVAC_MODE_HEAT + HVACMode ) @pytest.fixture def mock_client(): - mock = MagicMock() - state = State() state.current_temperature = 15.3 state.target_temperature = 33.3 - mock.configure_mock( - **{ - "get_state.return_value": state - } - ) + + mock = Mock(WebClient) + mock.get_state.return_value = state return mock @pytest.fixture def mock_ha_client(): - mock = MagicMock() + mock = Mock(HaTemperatureClient) - mock.configure_mock( - **{ - "current_temperature.return_value": 21.1 - } - ) + mock.current_temperature.return_value = 21.1 return mock -def test_entity_returns_target_temp_from_web_client(mock_client, mock_ha_client): +@pytest.mark.asyncio +async def test_entity_returns_target_temp_from_web_client(mock_client, mock_ha_client): target = Client(mock_client, mock_ha_client) - assert target.get_state().target_temperature == 33.3 + actual = await target.get_state() + + assert actual.target_temperature == 33.3 -def test_entity_returns_target_temp_from_home_assistant_client(mock_client, mock_ha_client): +@pytest.mark.asyncio +async def test_entity_returns_target_temp_from_home_assistant_client(mock_client, mock_ha_client): target = Client(mock_client, mock_ha_client) - assert target.get_state().current_temperature == 21.1 + actual = await target.get_state() + + assert actual.current_temperature == 21.1 -def test_entity_call_salus_client_only_once(mock_client, mock_ha_client): +@pytest.mark.asyncio +async def test_entity_call_salus_client_only_once(mock_client, mock_ha_client): target = Client(mock_client, mock_ha_client) - target.get_state() - target.get_state() + await target.get_state() + await target.get_state() mock_client.get_state.assert_called_once() - assert target.get_state().target_temperature == 33.3 + + actual = await target.get_state() + assert actual.target_temperature == 33.3 -def test_entity_delegates_set_temperature_salus_client(mock_client, mock_ha_client): +@pytest.mark.asyncio +async def test_entity_delegates_set_temperature_salus_client(mock_client, mock_ha_client): target = Client(mock_client, mock_ha_client) - target.set_temperature(temperature=29.9) + await target.set_temperature(temperature=29.9) mock_client.set_temperature.assert_called_once_with(29.9) -def test_entity_delegates_set_hvac_mode_to_salus_client(mock_client, mock_ha_client): +@pytest.mark.asyncio +async def test_entity_delegates_set_hvac_mode_to_salus_client(mock_client, mock_ha_client): target = Client(mock_client, mock_ha_client) - target.set_hvac_mode(hvac_mode=HVAC_MODE_HEAT) + await target.set_hvac_mode(hvac_mode=HVACMode.HEAT) - mock_client.set_hvac_mode.assert_called_once_with(HVAC_MODE_HEAT) \ No newline at end of file + mock_client.set_hvac_mode.assert_called_once_with(HVACMode.HEAT) \ No newline at end of file diff --git a/tests/test_climate.py b/tests/test_climate.py index 175a87d..5064aa5 100644 --- a/tests/test_climate.py +++ b/tests/test_climate.py @@ -38,6 +38,8 @@ async def test_entity_returns_mock_temperature(): registry = await setup_climate_platform() thermostat = registry.first + + await thermostat.async_update() assert thermostat.current_temperature == 15.9 @@ -47,5 +49,7 @@ async def test_entity_returns_mock_target_temperature(): registry = await setup_climate_platform() thermostat = registry.first + + await thermostat.async_update() assert thermostat.target_temperature == 20.1 \ No newline at end of file diff --git a/tests/test_thermostat_entity.py b/tests/test_thermostat_entity.py index a047e82..368c508 100644 --- a/tests/test_thermostat_entity.py +++ b/tests/test_thermostat_entity.py @@ -1,46 +1,51 @@ import pytest -from unittest.mock import MagicMock +from unittest.mock import Mock -from salusfy import ( ThermostatEntity, State ) +from salusfy import ( ThermostatEntity, State, WebClient ) from homeassistant.components.climate.const import ( - HVAC_MODE_HEAT + HVACMode ) @pytest.fixture def mock_client(): - mock = MagicMock() - state = State() state.current_temperature = 15.3 state.target_temperature = 33.3 - mock.configure_mock( - **{ - "get_state.return_value": state - } - ) + + mock = Mock(WebClient) + mock.get_state.return_value = state return mock -def test_entity_returns_target_temp_from_web_client(mock_client): +@pytest.mark.asyncio +async def test_entity_returns_target_temp_from_web_client(mock_client): target = ThermostatEntity('mock', mock_client) + await target.async_update() + assert target.target_temperature == 33.3 -def test_entity_delegates_set_temperature_web_client(mock_client): +@pytest.mark.asyncio +async def test_entity_delegates_set_temperature_web_client(mock_client): target = ThermostatEntity('mock', mock_client) - target.set_temperature(temperature=29.9) + await target.async_update() + + await target.set_temperature(temperature=29.9) mock_client.set_temperature.assert_called_once_with(29.9) assert target.target_temperature == 29.9 -def test_entity_delegates_set_hvac_mode_to_web_client(mock_client): +@pytest.mark.asyncio +async def test_entity_delegates_set_hvac_mode_to_web_client(mock_client): target = ThermostatEntity('mock', mock_client) - target.set_hvac_mode(hvac_mode=HVAC_MODE_HEAT) + await target.async_update() + + await target.set_hvac_mode(hvac_mode=HVACMode.HEAT) - mock_client.set_hvac_mode.assert_called_once_with(HVAC_MODE_HEAT) - assert target.hvac_mode == HVAC_MODE_HEAT \ No newline at end of file + mock_client.set_hvac_mode.assert_called_once_with(HVACMode.HEAT) + assert target.hvac_mode == HVACMode.HEAT \ No newline at end of file From 19a3150bf79c90d3b7f5765690891388c5278153 Mon Sep 17 00:00:00 2001 From: Matt Turner Date: Sun, 25 Feb 2024 07:38:38 -0600 Subject: [PATCH 37/55] Switch to aiohttp (#11) --- install.sh | 2 ++ requirements.txt | 2 -- run.py | 3 ++- salusfy/client.py | 5 +++++ salusfy/climate.py | 3 +++ salusfy/ha_temperature_client.py | 5 ++--- salusfy/manifest.json | 2 +- salusfy/thermostat_entity.py | 7 ++++++- salusfy/web_client.py | 22 ++++++++++++++-------- 9 files changed, 35 insertions(+), 16 deletions(-) diff --git a/install.sh b/install.sh index 433a451..a13b1e4 100644 --- a/install.sh +++ b/install.sh @@ -1,8 +1,10 @@ +#!bin/bash # expects the repository to be cloned within the homeassistant directory echo "Copying all files from the cloned repo to the Home Assistant custom_components directory..." cp --verbose ./salusfy/*.* ../custom_components/salusfy +cp --verbose ./salusfy/simulator/*.* ../custom_components/salusfy/simulator echo "Restart Home Assistant to apply the changes" \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index b1999d3..e69de29 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +0,0 @@ -requests -requests_async \ No newline at end of file diff --git a/run.py b/run.py index 2250c7c..6393aa1 100644 --- a/run.py +++ b/run.py @@ -29,7 +29,7 @@ async def main(): await thermostat.async_update() await thermostat.async_update() - await thermostat.set_hvac_mode(HVACMode.Heat) + await thermostat.set_hvac_mode(HVACMode.HEAT) await thermostat.set_temperature(temperature=9.8) print("Current: " + str(thermostat.current_temperature)) @@ -37,6 +37,7 @@ async def main(): print("HVAC Action: " + thermostat.hvac_action) print("HVAC Mode: " + thermostat.hvac_mode) + await thermostat.close() if __name__ == '__main__': loop = asyncio.new_event_loop() diff --git a/salusfy/client.py b/salusfy/client.py index ef7ac70..f471134 100644 --- a/salusfy/client.py +++ b/salusfy/client.py @@ -46,3 +46,8 @@ async def get_state(self): self._state.current_temperature = await self._temperature_client.current_temperature() return self._state + + + async def close(self): + """Closes the client session""" + await self._web_client.close() \ No newline at end of file diff --git a/salusfy/climate.py b/salusfy/climate.py index 89748f1..6c93d37 100644 --- a/salusfy/climate.py +++ b/salusfy/climate.py @@ -44,6 +44,7 @@ vol.Required(CONF_PASSWORD): cv.string, vol.Required(CONF_ID): cv.string, vol.Optional(CONF_SIMULATOR, default=False): cv.boolean, + vol.Optional(CONF_ENABLE_TEMPERATURE_CLIENT, default=True): cv.boolean, vol.Required(CONF_ENTITY_ID): cv.string, vol.Required(CONF_ACCESS_TOKEN): cv.string, vol.Optional(CONF_HOST, default='localhost'): cv.string @@ -56,6 +57,8 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= await async_setup_reload_service(hass, DOMAIN, PLATFORMS) client = create_client_from(config) + + await client.get_state() name = config.get(CONF_NAME) await async_add_entities( diff --git a/salusfy/ha_temperature_client.py b/salusfy/ha_temperature_client.py index 7c8344a..c390971 100644 --- a/salusfy/ha_temperature_client.py +++ b/salusfy/ha_temperature_client.py @@ -1,9 +1,8 @@ -from requests_async import get - """ Retrieves the current temperature from another entity from the Home Assistant API """ +import aiohttp class HaTemperatureClient: def __init__(self, host, entity_id, access_token): @@ -22,7 +21,7 @@ async def current_temperature(self): "Content-Type": "application/json", } - response = await get(url, headers=headers) + response = await aiohttp.get(url, headers=headers) body = response.json() diff --git a/salusfy/manifest.json b/salusfy/manifest.json index c27f279..1eff3f4 100644 --- a/salusfy/manifest.json +++ b/salusfy/manifest.json @@ -1,7 +1,7 @@ { "domain": "salusfy", "name": "Salus Thermostat", - "version": "0.1.0", + "version": "0.3.0", "documentation": "https://github.com/floringhimie/salusfy", "issue_tracker": "https://github.com/floringhimie/salusfy/issues", "requirements": [], diff --git a/salusfy/thermostat_entity.py b/salusfy/thermostat_entity.py index f7d09bd..ee02539 100644 --- a/salusfy/thermostat_entity.py +++ b/salusfy/thermostat_entity.py @@ -148,4 +148,9 @@ async def set_hvac_mode(self, hvac_mode): async def async_update(self): """Retrieve latest state data.""" - self._state = await self._client.get_state() \ No newline at end of file + self._state = await self._client.get_state() + + + async def close(self): + """Closes any client sessions held open""" + await self._client.close() \ No newline at end of file diff --git a/salusfy/web_client.py b/salusfy/web_client.py index c36595f..1285cb7 100644 --- a/salusfy/web_client.py +++ b/salusfy/web_client.py @@ -4,7 +4,7 @@ import time import logging import re -import requests_async as requests +import aiohttp import json from .state import State @@ -38,7 +38,7 @@ def __init__(self, username, password, id): self._token = None self._tokenRetrievedAt = None - self._session = requests.Session() + self._session = aiohttp.ClientSession() async def set_temperature(self, temperature): @@ -52,7 +52,7 @@ async def set_temperature(self, temperature): headers = {"Content-Type": "application/x-www-form-urlencoded"} try: - self._session.post(URL_SET_DATA, data=payload, headers=headers) + await self._session.post(URL_SET_DATA, data=payload, headers=headers) _LOGGER.info("Salusfy set_temperature: OK") except: _LOGGER.error("Error Setting the temperature.") @@ -75,7 +75,7 @@ async def set_hvac_mode(self, hvac_mode): payload = {"token": token, "devId": self._id, "auto": auto, "auto_setZ1": "1"} try: - self._session.post(URL_SET_DATA, data=payload, headers=headers) + await self._session.post(URL_SET_DATA, data=payload, headers=headers) except: _LOGGER.error("Error Setting HVAC mode to %s", hvac_mode) @@ -106,10 +106,11 @@ async def get_token(self): headers = {"Content-Type": "application/x-www-form-urlencoded"} try: - await self._session.post(URL_LOGIN, data=payload, headers=headers, verify=False) + await self._session.post(URL_LOGIN, data=payload, headers=headers) params = {"devId": self._id} getTkoken = await self._session.get(URL_GET_TOKEN, params=params) - result = re.search('', getTkoken.text) + body = await getTkoken.text() + result = re.search('', body) _LOGGER.info("Salusfy get_token OK") self._token = result.group(1) self._tokenRetrievedAt = time.time() @@ -137,8 +138,9 @@ async def get_state(self): _LOGGER.error("Error Getting the data from Web. Please check the connection to salus-it500.com manually.") return None - data = json.loads(r.text) - _LOGGER.info("Salusfy get_data output " + r.text) + body = await r.text() + _LOGGER.info("Salusfy get_data output " + body) + data = json.loads(body) state = State() state.target_temperature = float(data["CH1currentSetPoint"]) @@ -159,3 +161,7 @@ async def get_state(self): return state + + async def close(self): + """Closes the client session""" + await self._session.close() \ No newline at end of file From 43d7c2fe8f0f57030acd3c698583fb77fc6e4699 Mon Sep 17 00:00:00 2001 From: Matt Turner Date: Sun, 25 Feb 2024 09:34:25 -0600 Subject: [PATCH 38/55] Switch to aiohttp (#12) --- run.py | 5 +- salusfy/client.py | 7 +-- salusfy/ha_temperature_client.py | 20 +++---- salusfy/thermostat_entity.py | 12 ++--- salusfy/web_client.py | 90 +++++++++++++++++--------------- 5 files changed, 64 insertions(+), 70 deletions(-) diff --git a/run.py b/run.py index 6393aa1..b9c01e8 100644 --- a/run.py +++ b/run.py @@ -9,9 +9,7 @@ from tests.config_adapter import ConfigAdapter from tests.entity_registry import EntityRegistry -from homeassistant.components.climate.const import ( - HVACMode -) +from homeassistant.components.climate.const import HVACMode import config @@ -37,7 +35,6 @@ async def main(): print("HVAC Action: " + thermostat.hvac_action) print("HVAC Mode: " + thermostat.hvac_mode) - await thermostat.close() if __name__ == '__main__': loop = asyncio.new_event_loop() diff --git a/salusfy/client.py b/salusfy/client.py index f471134..58b7faf 100644 --- a/salusfy/client.py +++ b/salusfy/client.py @@ -45,9 +45,4 @@ async def get_state(self): _LOGGER.info("Updating current temperature from temperature client...") self._state.current_temperature = await self._temperature_client.current_temperature() - return self._state - - - async def close(self): - """Closes the client session""" - await self._web_client.close() \ No newline at end of file + return self._state \ No newline at end of file diff --git a/salusfy/ha_temperature_client.py b/salusfy/ha_temperature_client.py index c390971..c8a114d 100644 --- a/salusfy/ha_temperature_client.py +++ b/salusfy/ha_temperature_client.py @@ -21,15 +21,17 @@ async def current_temperature(self): "Content-Type": "application/json", } - response = await aiohttp.get(url, headers=headers) - - body = response.json() + + async with aiohttp.ClientSession() as session: + async with session.get(url, headers=headers) as response: - if 'state' not in body: - return None + body = await response.json() - state = body['state'] - if state == 'unavailable': - return None + if 'state' not in body: + return None + + state = body['state'] + if state == 'unavailable': + return None - return float(state) \ No newline at end of file + return float(state) \ No newline at end of file diff --git a/salusfy/thermostat_entity.py b/salusfy/thermostat_entity.py index ee02539..678ae40 100644 --- a/salusfy/thermostat_entity.py +++ b/salusfy/thermostat_entity.py @@ -10,8 +10,7 @@ from homeassistant.components.climate.const import ( HVACAction, HVACMode, - ClimateEntityFeature, - SUPPORT_PRESET_MODE + ClimateEntityFeature ) from homeassistant.const import ( @@ -117,7 +116,7 @@ def preset_mode(self): @property def preset_modes(self): """Return a list of available preset modes.""" - return SUPPORT_PRESET_MODE + return ClimateEntityFeature.PRESET_MODE async def set_temperature(self, **kwargs): @@ -148,9 +147,4 @@ async def set_hvac_mode(self, hvac_mode): async def async_update(self): """Retrieve latest state data.""" - self._state = await self._client.get_state() - - - async def close(self): - """Closes any client sessions held open""" - await self._client.close() \ No newline at end of file + self._state = await self._client.get_state() \ No newline at end of file diff --git a/salusfy/web_client.py b/salusfy/web_client.py index 1285cb7..46b452d 100644 --- a/salusfy/web_client.py +++ b/salusfy/web_client.py @@ -37,8 +37,6 @@ def __init__(self, username, password, id): self._id = id self._token = None self._tokenRetrievedAt = None - - self._session = aiohttp.ClientSession() async def set_temperature(self, temperature): @@ -46,16 +44,17 @@ async def set_temperature(self, temperature): _LOGGER.info("Setting the temperature to %.1f...", temperature) - token = await self.obtain_token() + async with aiohttp.ClientSession() as session: + token = await self.obtain_token(session) - payload = {"token": token, "devId": self._id, "tempUnit": "0", "current_tempZ1_set": "1", "current_tempZ1": temperature} - headers = {"Content-Type": "application/x-www-form-urlencoded"} - - try: - await self._session.post(URL_SET_DATA, data=payload, headers=headers) - _LOGGER.info("Salusfy set_temperature: OK") - except: - _LOGGER.error("Error Setting the temperature.") + payload = {"token": token, "devId": self._id, "tempUnit": "0", "current_tempZ1_set": "1", "current_tempZ1": temperature} + headers = {"Content-Type": "application/x-www-form-urlencoded"} + + try: + await session.post(URL_SET_DATA, data=payload, headers=headers) + _LOGGER.info("Salusfy set_temperature: OK") + except: + _LOGGER.error("Error Setting the temperature.") async def set_hvac_mode(self, hvac_mode): @@ -71,21 +70,22 @@ async def set_hvac_mode(self, hvac_mode): elif hvac_mode == HVAC_MODE_HEAT: auto = "0" - token = await self.obtain_token() - - payload = {"token": token, "devId": self._id, "auto": auto, "auto_setZ1": "1"} - try: - await self._session.post(URL_SET_DATA, data=payload, headers=headers) - except: - _LOGGER.error("Error Setting HVAC mode to %s", hvac_mode) + async with aiohttp.ClientSession() as session: + token = await self.obtain_token(session) + + payload = {"token": token, "devId": self._id, "auto": auto, "auto_setZ1": "1"} + try: + await session.post(URL_SET_DATA, data=payload, headers=headers) + except: + _LOGGER.error("Error Setting HVAC mode to %s", hvac_mode) - async def obtain_token(self): + async def obtain_token(self, session): """Gets the existing session token of the thermostat or retrieves a new one if expired.""" if self._token is None: _LOGGER.info("Retrieving token for the first time this session...") - await self.get_token() + await self.get_token(session) return self._token if self._tokenRetrievedAt > time.time() - MAX_TOKEN_AGE_SECONDS: @@ -93,11 +93,11 @@ async def obtain_token(self): return self._token _LOGGER.info("Token has expired, getting new one...") - await self.get_token() + await self.get_token(session) return self._token - async def get_token(self): + async def get_token(self, session): """Get the Session Token of the Thermostat.""" _LOGGER.info("Getting token from Salus...") @@ -106,9 +106,9 @@ async def get_token(self): headers = {"Content-Type": "application/x-www-form-urlencoded"} try: - await self._session.post(URL_LOGIN, data=payload, headers=headers) + await session.post(URL_LOGIN, data=payload, headers=headers) params = {"devId": self._id} - getTkoken = await self._session.get(URL_GET_TOKEN, params=params) + getTkoken = await session.get(URL_GET_TOKEN, params=params) body = await getTkoken.text() result = re.search('', body) _LOGGER.info("Salusfy get_token OK") @@ -126,21 +126,7 @@ async def get_state(self): _LOGGER.info("Retrieving current state from Salus Gateway...") - token = await self.obtain_token() - - params = {"devId": self._id, "token": token, "&_": str(int(round(time.time() * 1000)))} - try: - r = await self._session.get(url = URL_GET_DATA, params = params) - if not r: - _LOGGER.error("Could not get data from Salus.") - return None - except: - _LOGGER.error("Error Getting the data from Web. Please check the connection to salus-it500.com manually.") - return None - - body = await r.text() - _LOGGER.info("Salusfy get_data output " + body) - data = json.loads(body) + data = await self.get_state_data() state = State() state.target_temperature = float(data["CH1currentSetPoint"]) @@ -162,6 +148,26 @@ async def get_state(self): return state - async def close(self): - """Closes the client session""" - await self._session.close() \ No newline at end of file + async def get_state_data(self): + """Retrieves the raw state from the Salus gateway""" + + _LOGGER.info("Retrieving raw state from Salus Gateway...") + + async with aiohttp.ClientSession() as session: + token = await self.obtain_token(session) + + params = {"devId": self._id, "token": token, "&_": str(int(round(time.time() * 1000)))} + try: + r = await session.get(url = URL_GET_DATA, params = params) + if not r: + _LOGGER.error("Could not get data from Salus.") + return None + except: + _LOGGER.error("Error Getting the data from Web. Please check the connection to salus-it500.com manually.") + return None + + body = await r.text() + _LOGGER.info("Salusfy get_data output " + body) + data = json.loads(body) + + return data \ No newline at end of file From c0518e54b87f8411244c369c929b18df8937e761 Mon Sep 17 00:00:00 2001 From: Matt Turner Date: Sun, 25 Feb 2024 10:31:25 -0600 Subject: [PATCH 39/55] Fix startup (#13) --- salusfy/climate.py | 6 ++---- salusfy/thermostat_entity.py | 12 +++++++++++- tests/entity_registry.py | 2 +- 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/salusfy/climate.py b/salusfy/climate.py index 6c93d37..513eba4 100644 --- a/salusfy/climate.py +++ b/salusfy/climate.py @@ -44,7 +44,7 @@ vol.Required(CONF_PASSWORD): cv.string, vol.Required(CONF_ID): cv.string, vol.Optional(CONF_SIMULATOR, default=False): cv.boolean, - vol.Optional(CONF_ENABLE_TEMPERATURE_CLIENT, default=True): cv.boolean, + vol.Optional(CONF_ENABLE_TEMPERATURE_CLIENT, default=False): cv.boolean, vol.Required(CONF_ENTITY_ID): cv.string, vol.Required(CONF_ACCESS_TOKEN): cv.string, vol.Optional(CONF_HOST, default='localhost'): cv.string @@ -57,11 +57,9 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= await async_setup_reload_service(hass, DOMAIN, PLATFORMS) client = create_client_from(config) - - await client.get_state() name = config.get(CONF_NAME) - await async_add_entities( + async_add_entities( [ThermostatEntity(name, client)] ) diff --git a/salusfy/thermostat_entity.py b/salusfy/thermostat_entity.py index 678ae40..2e49f31 100644 --- a/salusfy/thermostat_entity.py +++ b/salusfy/thermostat_entity.py @@ -7,6 +7,8 @@ MIN_TEMP ) +from .state import State + from homeassistant.components.climate.const import ( HVACAction, HVACMode, @@ -32,7 +34,7 @@ def __init__(self, name, client): """Initialize the thermostat.""" self._name = name self._client = client - self._state = None + self._state = State() @property @@ -144,6 +146,14 @@ async def set_hvac_mode(self, hvac_mode): self._state.current_operation_mode = STATE_ON self._state.status = STATE_ON + + async def turn_off(self) -> None: + await self.set_hvac_mode(HVACAction.OFF) + + + async def turn_on(self) -> None: + await self.set_hvac_mode(HVACAction.HEATING) + async def async_update(self): """Retrieve latest state data.""" diff --git a/tests/entity_registry.py b/tests/entity_registry.py index 7b7af3e..933c450 100644 --- a/tests/entity_registry.py +++ b/tests/entity_registry.py @@ -2,7 +2,7 @@ class EntityRegistry: def __init__(self): self._entities = [] - async def register(self, list): + def register(self, list): self._entities.extend(list) @property From 8c57a85751a906269c7e723cd38e360412024bf5 Mon Sep 17 00:00:00 2001 From: Matt Turner Date: Sun, 25 Feb 2024 13:15:45 -0600 Subject: [PATCH 40/55] Ensure async methods are called (#14) --- run.py | 4 ++-- salusfy/climate.py | 2 +- salusfy/thermostat_entity.py | 18 +++++++----------- tests/entity_registry.py | 14 ++++++++++++-- tests/test_climate.py | 7 +++++++ tests/test_thermostat_entity.py | 4 ++-- 6 files changed, 31 insertions(+), 18 deletions(-) diff --git a/run.py b/run.py index b9c01e8..de34983 100644 --- a/run.py +++ b/run.py @@ -27,8 +27,8 @@ async def main(): await thermostat.async_update() await thermostat.async_update() - await thermostat.set_hvac_mode(HVACMode.HEAT) - await thermostat.set_temperature(temperature=9.8) + await thermostat.async_set_hvac_mode(HVACMode.HEAT) + await thermostat.async_set_temperature(temperature=9.8) print("Current: " + str(thermostat.current_temperature)) print("Target: " + str(thermostat.target_temperature)) diff --git a/salusfy/climate.py b/salusfy/climate.py index 513eba4..dbcbbb6 100644 --- a/salusfy/climate.py +++ b/salusfy/climate.py @@ -60,7 +60,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= name = config.get(CONF_NAME) async_add_entities( - [ThermostatEntity(name, client)] + [ThermostatEntity(name, client)], update_before_add=True ) diff --git a/salusfy/thermostat_entity.py b/salusfy/thermostat_entity.py index 2e49f31..a1fdda9 100644 --- a/salusfy/thermostat_entity.py +++ b/salusfy/thermostat_entity.py @@ -7,8 +7,6 @@ MIN_TEMP ) -from .state import State - from homeassistant.components.climate.const import ( HVACAction, HVACMode, @@ -25,7 +23,6 @@ except ImportError: from homeassistant.components.climate import ClimateDevice as ClimateEntity -SUPPORT_FLAGS = ClimateEntityFeature.TARGET_TEMPERATURE class ThermostatEntity(ClimateEntity): """Representation of a Salus Thermostat device.""" @@ -34,13 +31,12 @@ def __init__(self, name, client): """Initialize the thermostat.""" self._name = name self._client = client - self._state = State() @property def supported_features(self): """Return the list of supported features.""" - return SUPPORT_FLAGS + return ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.TURN_ON | ClimateEntityFeature.TURN_OFF @property def name(self): @@ -121,7 +117,7 @@ def preset_modes(self): return ClimateEntityFeature.PRESET_MODE - async def set_temperature(self, **kwargs): + async def async_set_temperature(self, **kwargs) -> None: """Set new target temperature.""" temperature = kwargs.get(ATTR_TEMPERATURE) @@ -134,7 +130,7 @@ async def set_temperature(self, **kwargs): self._state.target_temperature = temperature - async def set_hvac_mode(self, hvac_mode): + async def async_set_hvac_mode(self, hvac_mode) -> None: """Set HVAC mode, via URL commands.""" await self._client.set_hvac_mode(hvac_mode) @@ -147,12 +143,12 @@ async def set_hvac_mode(self, hvac_mode): self._state.status = STATE_ON - async def turn_off(self) -> None: - await self.set_hvac_mode(HVACAction.OFF) + async def async_turn_off(self) -> None: + await self.async_set_hvac_mode(HVACMode.OFF) - async def turn_on(self) -> None: - await self.set_hvac_mode(HVACAction.HEATING) + async def async_turn_on(self) -> None: + await self.async_set_hvac_mode(HVACMode.HEAT) async def async_update(self): diff --git a/tests/entity_registry.py b/tests/entity_registry.py index 933c450..7f4fbb1 100644 --- a/tests/entity_registry.py +++ b/tests/entity_registry.py @@ -1,14 +1,24 @@ class EntityRegistry: def __init__(self): self._entities = [] + self._update_before_add = False - def register(self, list): + + def register(self, list, **kwargs): + self._update_before_add = kwargs.get('update_before_add') self._entities.extend(list) + @property def entities(self): return self._entities + @property def first(self): - return self._entities[0] \ No newline at end of file + return self._entities[0] + + + @property + def update_before_add(self): + return self._update_before_add \ No newline at end of file diff --git a/tests/test_climate.py b/tests/test_climate.py index 5064aa5..1c2a22c 100644 --- a/tests/test_climate.py +++ b/tests/test_climate.py @@ -33,6 +33,13 @@ async def test_entity_is_registered(): assert len(registry.entities) == 1 +@pytest.mark.asyncio +async def test_entity_is_updated_before_added(): + registry = await setup_climate_platform() + + assert registry.update_before_add == True + + @pytest.mark.asyncio async def test_entity_returns_mock_temperature(): registry = await setup_climate_platform() diff --git a/tests/test_thermostat_entity.py b/tests/test_thermostat_entity.py index 368c508..57a2afb 100644 --- a/tests/test_thermostat_entity.py +++ b/tests/test_thermostat_entity.py @@ -33,7 +33,7 @@ async def test_entity_delegates_set_temperature_web_client(mock_client): await target.async_update() - await target.set_temperature(temperature=29.9) + await target.async_set_temperature(temperature=29.9) mock_client.set_temperature.assert_called_once_with(29.9) assert target.target_temperature == 29.9 @@ -45,7 +45,7 @@ async def test_entity_delegates_set_hvac_mode_to_web_client(mock_client): await target.async_update() - await target.set_hvac_mode(hvac_mode=HVACMode.HEAT) + await target.async_set_hvac_mode(hvac_mode=HVACMode.HEAT) mock_client.set_hvac_mode.assert_called_once_with(HVACMode.HEAT) assert target.hvac_mode == HVACMode.HEAT \ No newline at end of file From 18aff230c29f9b86e089c9825feb15a32d63ee8d Mon Sep 17 00:00:00 2001 From: Matt Turner Date: Sun, 25 Feb 2024 13:31:27 -0600 Subject: [PATCH 41/55] Suppress warnings (#15) --- salusfy/services.yaml | 1 + salusfy/thermostat_entity.py | 2 ++ 2 files changed, 3 insertions(+) create mode 100644 salusfy/services.yaml diff --git a/salusfy/services.yaml b/salusfy/services.yaml new file mode 100644 index 0000000..0149aa0 --- /dev/null +++ b/salusfy/services.yaml @@ -0,0 +1 @@ +# empty services to prevent warning \ No newline at end of file diff --git a/salusfy/thermostat_entity.py b/salusfy/thermostat_entity.py index a1fdda9..8e38c4d 100644 --- a/salusfy/thermostat_entity.py +++ b/salusfy/thermostat_entity.py @@ -32,6 +32,8 @@ def __init__(self, name, client): self._name = name self._client = client + self._enable_turn_on_off_backwards_compatibility = False + @property def supported_features(self): From bb7e0a4affc0471fbc4eb4f9ddc488f5aa4f52a8 Mon Sep 17 00:00:00 2001 From: Matt Turner Date: Mon, 26 Feb 2024 02:20:35 -0600 Subject: [PATCH 42/55] Remove HEAT custom constant (#16) --- salusfy/__init__.py | 2 -- salusfy/simulator/web_client.py | 4 +--- salusfy/web_client.py | 7 +++---- 3 files changed, 4 insertions(+), 9 deletions(-) diff --git a/salusfy/__init__.py b/salusfy/__init__.py index c2d7794..0ccfce7 100644 --- a/salusfy/__init__.py +++ b/salusfy/__init__.py @@ -3,8 +3,6 @@ from .state import State from .web_client import ( WebClient, - HVAC_MODE_HEAT, - HVAC_MODE_OFF, STATE_ON, STATE_OFF ) diff --git a/salusfy/simulator/web_client.py b/salusfy/simulator/web_client.py index 3e18b83..a09deb2 100644 --- a/salusfy/simulator/web_client.py +++ b/salusfy/simulator/web_client.py @@ -3,9 +3,7 @@ """ import logging -from homeassistant.components.climate.const import ( - HVACMode, -) +from homeassistant.components.climate.const import HVACMode from .. import ( State, diff --git a/salusfy/web_client.py b/salusfy/web_client.py index 46b452d..7615b49 100644 --- a/salusfy/web_client.py +++ b/salusfy/web_client.py @@ -9,8 +9,7 @@ from .state import State -HVAC_MODE_HEAT = "heat" -HVAC_MODE_OFF = "off" +from homeassistant.components.climate.const import HVACMode STATE_ON = "ON" STATE_OFF = "OFF" @@ -65,9 +64,9 @@ async def set_hvac_mode(self, hvac_mode): headers = {"Content-Type": "application/x-www-form-urlencoded"} auto = "1" - if hvac_mode == HVAC_MODE_OFF: + if hvac_mode == HVACMode.OFF: auto = "1" - elif hvac_mode == HVAC_MODE_HEAT: + elif hvac_mode == HVACMode.HEAT: auto = "0" async with aiohttp.ClientSession() as session: From 838cfea30ff9a8b8317b2e2f1fe7ee5b36bcb9f8 Mon Sep 17 00:00:00 2001 From: Matt Turner Date: Mon, 26 Feb 2024 12:46:08 -0600 Subject: [PATCH 43/55] Switch to hvac action (#17) --- salusfy/__init__.py | 2 -- salusfy/simulator/web_client.py | 11 ++--------- salusfy/state.py | 4 ++-- salusfy/thermostat_entity.py | 35 +++++++++++---------------------- salusfy/web_client.py | 15 +++++++------- 5 files changed, 23 insertions(+), 44 deletions(-) diff --git a/salusfy/__init__.py b/salusfy/__init__.py index 0ccfce7..5c63896 100644 --- a/salusfy/__init__.py +++ b/salusfy/__init__.py @@ -3,8 +3,6 @@ from .state import State from .web_client import ( WebClient, - STATE_ON, - STATE_OFF ) from .thermostat_entity import ThermostatEntity diff --git a/salusfy/simulator/web_client.py b/salusfy/simulator/web_client.py index a09deb2..0efbbd7 100644 --- a/salusfy/simulator/web_client.py +++ b/salusfy/simulator/web_client.py @@ -5,11 +5,7 @@ from homeassistant.components.climate.const import HVACMode -from .. import ( - State, - STATE_ON, - STATE_OFF -) +from ..state import State _LOGGER = logging.getLogger(__name__) @@ -39,10 +35,7 @@ async def set_hvac_mode(self, hvac_mode): _LOGGER.info("Setting the HVAC mode to %s...", hvac_mode) - if hvac_mode == HVACMode.OFF: - self._state.current_operation_mode = STATE_OFF - elif hvac_mode == HVACMode.HEAT: - self._state.current_operation_mode = STATE_ON + self._state.mode = hvac_mode async def get_state(self): diff --git a/salusfy/state.py b/salusfy/state.py index 641f371..1a1152e 100644 --- a/salusfy/state.py +++ b/salusfy/state.py @@ -4,5 +4,5 @@ def __init__(self): self.current_temperature = None self.target_temperature = None self.frost = None - self.status = None - self.current_operation_mode = None \ No newline at end of file + self.action = None + self.mode = None \ No newline at end of file diff --git a/salusfy/thermostat_entity.py b/salusfy/thermostat_entity.py index 8e38c4d..a64b67a 100644 --- a/salusfy/thermostat_entity.py +++ b/salusfy/thermostat_entity.py @@ -1,8 +1,6 @@ import logging from .web_client import ( - STATE_ON, - STATE_OFF, MAX_TEMP, MIN_TEMP ) @@ -10,7 +8,8 @@ from homeassistant.components.climate.const import ( HVACAction, HVACMode, - ClimateEntityFeature + ClimateEntityFeature, + PRESET_NONE, ) from homeassistant.const import ( @@ -84,35 +83,28 @@ def target_temperature(self): @property def hvac_mode(self): """Return hvac operation ie. heat, cool mode.""" - try: - climate_mode = self._state.current_operation_mode - curr_hvac_mode = HVACMode.OFF - if climate_mode == STATE_ON: - curr_hvac_mode = HVACMode.HEAT - else: - curr_hvac_mode = HVACMode.OFF - except KeyError: - return HVACMode.OFF - return curr_hvac_mode + + return self._state.mode + @property def hvac_modes(self): """HVAC modes.""" return [HVACMode.HEAT, HVACMode.OFF] + @property def hvac_action(self): """Return the current running hvac operation.""" - if self._state.status == STATE_ON: - return HVACAction.HEATING - return HVACAction.IDLE + return self._state.action @property def preset_mode(self): """Return the current preset mode, e.g., home, away, temp.""" - return self._state.status - + return PRESET_NONE + + @property def preset_modes(self): """Return a list of available preset modes.""" @@ -137,12 +129,7 @@ async def async_set_hvac_mode(self, hvac_mode) -> None: await self._client.set_hvac_mode(hvac_mode) - if hvac_mode == HVACMode.OFF: - self._state.current_operation_mode = STATE_OFF - self._state.status = STATE_OFF - elif hvac_mode == HVACMode.HEAT: - self._state.current_operation_mode = STATE_ON - self._state.status = STATE_ON + self._state.mode = hvac_mode async def async_turn_off(self) -> None: diff --git a/salusfy/web_client.py b/salusfy/web_client.py index 7615b49..be6a732 100644 --- a/salusfy/web_client.py +++ b/salusfy/web_client.py @@ -9,10 +9,11 @@ from .state import State -from homeassistant.components.climate.const import HVACMode +from homeassistant.components.climate.const import ( + HVACMode, + HVACAction, +) -STATE_ON = "ON" -STATE_OFF = "OFF" _LOGGER = logging.getLogger(__name__) @@ -134,15 +135,15 @@ async def get_state(self): status = data['CH1heatOnOffStatus'] if status == "1": - state.status = STATE_ON + state.action = HVACAction.HEATING else: - state.status = STATE_OFF + state.action = HVACAction.IDLE mode = data['CH1heatOnOff'] if mode == "1": - state.current_operation_mode = STATE_OFF + state.mode = HVACMode.OFF else: - state.current_operation_mode = STATE_ON + state.mode = HVACMode.HEAT return state From 732d3d330878c21320ecb1552fa22db460e8c591 Mon Sep 17 00:00:00 2001 From: Matt Turner Date: Mon, 26 Feb 2024 14:08:17 -0600 Subject: [PATCH 44/55] Update readme (#18) --- README.md | 60 +++++++++++++++++++++++++++++++++++++----------- config.sample.py | 8 ++++--- 2 files changed, 52 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index db7b0c3..b398621 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Custom Components for Home-Assistant (http://www.home-assistant.io) # Salus Thermostat Climate Component -My device is RT301i, it is working with it500 thermostat, the ideea is simple if you have a Salus Thermostat and you are able to login to salus-it500.com and control it from this page, this custom component should work. +My device is RT301i, it is working with it500 thermostat, the idea is simple if you have a Salus Thermostat and you are able to login to salus-it500.com and control it from this page, this custom component should work. ## Component to interface with the salus-it500.com. It reads the Current Temperature, Set Temperature, Current HVAC Mode, Current Relay Mode. @@ -12,15 +12,16 @@ Keep in mind this is my first custom component and this is also the first versio **** This is not an official integration. ### Installation -* If not exist, in config/custom_components/ create a directory called salusfy -* Copy all files in salusfy to your config/custom_components/salusfy/ directory. -* Configure with config below. -* Restart Home-Assistant. +1. Clone the repo into the `home_assistant` directory +1. Change directory into the `salusfy` directory +1. Run install.sh (you may need to fix the line endings) +1. Configure with config below +1. Restart Home Assistant ### Usage To use this component in your installation, add the following to your configuration.yaml file: -### Example configuration.yaml entry +#### Example configuration.yaml entry ``` climate: @@ -28,8 +29,6 @@ climate: username: "EMAIL" password: "PASSWORD" id: "DEVICE_ID" - entity_id: "sensor.temperature" - access_token: "ha_long_lived_token" ``` ![image](https://user-images.githubusercontent.com/33951255/140300295-4915a18f-f5d4-4957-b513-59d7736cc52a.png) ![image](https://user-images.githubusercontent.com/33951255/140303472-fd38b9e4-5c33-408f-afef-25547c39551c.png) @@ -43,10 +42,45 @@ climate: ![image](https://user-images.githubusercontent.com/33951255/140301260-151b6af9-dbc4-4e90-a14e-29018fe2e482.png) -### Known issues -Due to how chatty the HA integration is, the salus-it500.com server may start blocking your public IP address (and rightly so). This will prevent the gateway and mobile client from connecting. This implementation aims to resolve this by: +### Separate Temperature Client +Due to how chatty Home Assistant integrations are, the salus-it500.com server may start blocking your public IP address. This will prevent the gateway and mobile client from connecting. To resolve this, you can use the `TemperatureClient` which: -* suppressing requests to Salus in many circumstances -* querying another entity for current temperature +* suppresses requests to Salus for reading the current temperature +* queries another Home Assistant entity for current temperature via the HA API -The effect of this is that the target temperature/status values may be out of date if it has been outside of HA, but the main control features (target temperature, set status etc) will still work. \ No newline at end of file +The effect of this is that the target temperature/mode values may be out of date **if they have been updated outside of HA**, but the main control features (target temperature, set mode etc) will still work. + +To enable the `TemperatureClient`, set the following settings in `climate.yaml`: + +``` +climate: + - platform: salusfy + username: "EMAIL" + password: "PASSWORD" + id: "DEVICE_ID" + enable_temperature_client: True + host: "your-home-assistant-ip-address" + entity_id: "sensor.your-temperature-sensor" + access_token: "your-HA-access-token" +``` + +### Running Locally + +You can exercise the integration locally using the `run.py` which calls the code on your local machine as if it was being run within Home Assistant. This can help debug any issues you may be having without waiting for multiple Home Assistant restarts. + +To get going: + +1. Copy `config.sample.py` to `config.py` +1. Replace the config (below) with the appropriate values for your installation +1. Run `python ./run.py` + +Feel free to change the code to exercise different methods and configuration. + +#### Example config.py + +``` +ENABLE_TEMPERATURE_CLIENT = True +HOST = "your-home-assistant-ip-address" +ENTITY_ID = "sensor.your-temperature-sensor" +ACCESS_TOKEN = "your-HA-access-token" +``` \ No newline at end of file diff --git a/config.sample.py b/config.sample.py index 71e735a..4c097ff 100644 --- a/config.sample.py +++ b/config.sample.py @@ -7,6 +7,8 @@ PASSWORD = "replace" DEVICE_ID = "replace" -SIMULATOR = True -ENABLE_TEMPERATURE_CLIENT = True -HOST = "192.168.0.9" \ No newline at end of file +SIMULATOR = False +ENABLE_TEMPERATURE_CLIENT = False +HOST = "your-home-assistant-ip-address" +ENTITY_ID = "sensor.your-temperature-sensor" +ACCESS_TOKEN = "your-HA-access-token" \ No newline at end of file From 9dfbea0bc97e78933e40c489bf81c0bbc4320a2f Mon Sep 17 00:00:00 2001 From: Matt Turner Date: Tue, 27 Feb 2024 01:43:04 -0600 Subject: [PATCH 45/55] Make temperature client config values optional (#19) --- salusfy/climate.py | 14 ++++++++------ tests/config_adapter.py | 8 ++++++-- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/salusfy/climate.py b/salusfy/climate.py index dbcbbb6..44bbe1e 100644 --- a/salusfy/climate.py +++ b/salusfy/climate.py @@ -45,8 +45,8 @@ vol.Required(CONF_ID): cv.string, vol.Optional(CONF_SIMULATOR, default=False): cv.boolean, vol.Optional(CONF_ENABLE_TEMPERATURE_CLIENT, default=False): cv.boolean, - vol.Required(CONF_ENTITY_ID): cv.string, - vol.Required(CONF_ACCESS_TOKEN): cv.string, + vol.Optional(CONF_ENTITY_ID, default=''): cv.string, + vol.Optional(CONF_ACCESS_TOKEN, default=''): cv.string, vol.Optional(CONF_HOST, default='localhost'): cv.string } ) @@ -69,10 +69,6 @@ def create_client_from(config): password = config.get(CONF_PASSWORD) id = config.get(CONF_ID) enable_simulator = config.get(CONF_SIMULATOR) - enable_temperature_client = config.get(CONF_ENABLE_TEMPERATURE_CLIENT) - entity_id = config.get(CONF_ENTITY_ID) - host = config.get(CONF_HOST) - access_token = config.get(CONF_ACCESS_TOKEN) if enable_simulator: _LOGGER.info('Registering Salus Thermostat client simulator...') @@ -81,11 +77,17 @@ def create_client_from(config): web_client = WebClient(username, password, id) + enable_temperature_client = config.get(CONF_ENABLE_TEMPERATURE_CLIENT) + if not enable_temperature_client: _LOGGER.info('Registering Salus Thermostat client...') return web_client + entity_id = config.get(CONF_ENTITY_ID) + host = config.get(CONF_HOST) + access_token = config.get(CONF_ACCESS_TOKEN) + _LOGGER.info('Registering Salus Thermostat client with Temperature client...') ha_client = HaTemperatureClient(host, entity_id, access_token) diff --git a/tests/config_adapter.py b/tests/config_adapter.py index f10d344..ef67136 100644 --- a/tests/config_adapter.py +++ b/tests/config_adapter.py @@ -17,10 +17,14 @@ def get(self, key): return self._config.PASSWORD if (key == 'simulator'): - return self._config.SIMULATOR + if hasattr(self._config, 'SIMULATOR'): + return self._config.SIMULATOR + return False if (key == 'enable_temperature_client'): - return self._config.ENABLE_TEMPERATURE_CLIENT + if hasattr(self._config, 'ENABLE_TEMPERATURE_CLIENT'): + return self._config.ENABLE_TEMPERATURE_CLIENT + return False if (key == 'host'): return self._config.HOST From 03d6bd8bbe2d3aaf972551982fd1c736a581ce84 Mon Sep 17 00:00:00 2001 From: Matt Turner Date: Tue, 27 Feb 2024 02:31:32 -0600 Subject: [PATCH 46/55] Assume hvac action based on current/target temp (#21) --- salusfy/client.py | 28 ++++++++++++ tests/test_client.py | 101 ++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 122 insertions(+), 7 deletions(-) diff --git a/salusfy/client.py b/salusfy/client.py index 58b7faf..e5a0152 100644 --- a/salusfy/client.py +++ b/salusfy/client.py @@ -7,6 +7,11 @@ from .web_client import WebClient from .ha_temperature_client import HaTemperatureClient +from homeassistant.components.climate.const import ( + HVACMode, + HVACAction +) + _LOGGER = logging.getLogger(__name__) class Client: @@ -26,6 +31,10 @@ async def set_temperature(self, temperature): await self._web_client.set_temperature(temperature) + self._state.target_temperature = temperature + + self.assume_hvac_action() + async def set_hvac_mode(self, hvac_mode): """Set HVAC mode, via URL commands.""" @@ -34,6 +43,25 @@ async def set_hvac_mode(self, hvac_mode): await self._web_client.set_hvac_mode(hvac_mode) + self._state.mode = hvac_mode + + self.assume_hvac_action() + + + def assume_hvac_action(self): + if self._state.mode == HVACMode.OFF: + _LOGGER.info("Assuming action is IDLE...") + self._state.action = HVACAction.IDLE + return + + if self._state.target_temperature > self._state.current_temperature: + _LOGGER.info("Assuming action is HEATING based on target temperature...") + self._state.action = HVACAction.HEATING + return + + _LOGGER.info("Assuming action is IDLE based on target temperature...") + self._state.action = HVACAction.IDLE + async def get_state(self): """Retrieves the status""" diff --git a/tests/test_client.py b/tests/test_client.py index ed74847..4ab333d 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -3,7 +3,8 @@ from salusfy import ( Client, State, WebClient, HaTemperatureClient ) from homeassistant.components.climate.const import ( - HVACMode + HVACMode, + HVACAction ) @pytest.fixture @@ -28,7 +29,7 @@ def mock_ha_client(): @pytest.mark.asyncio -async def test_entity_returns_target_temp_from_web_client(mock_client, mock_ha_client): +async def test_client_returns_target_temp_from_web_client(mock_client, mock_ha_client): target = Client(mock_client, mock_ha_client) actual = await target.get_state() @@ -37,7 +38,7 @@ async def test_entity_returns_target_temp_from_web_client(mock_client, mock_ha_c @pytest.mark.asyncio -async def test_entity_returns_target_temp_from_home_assistant_client(mock_client, mock_ha_client): +async def test_client_returns_target_temp_from_home_assistant_client(mock_client, mock_ha_client): target = Client(mock_client, mock_ha_client) actual = await target.get_state() @@ -46,7 +47,7 @@ async def test_entity_returns_target_temp_from_home_assistant_client(mock_client @pytest.mark.asyncio -async def test_entity_call_salus_client_only_once(mock_client, mock_ha_client): +async def test_client_call_salus_client_only_once(mock_client, mock_ha_client): target = Client(mock_client, mock_ha_client) await target.get_state() @@ -59,18 +60,104 @@ async def test_entity_call_salus_client_only_once(mock_client, mock_ha_client): @pytest.mark.asyncio -async def test_entity_delegates_set_temperature_salus_client(mock_client, mock_ha_client): +async def test_client_delegates_set_temperature_salus_client(mock_client, mock_ha_client): target = Client(mock_client, mock_ha_client) + await target.get_state() + await target.set_temperature(temperature=29.9) mock_client.set_temperature.assert_called_once_with(29.9) @pytest.mark.asyncio -async def test_entity_delegates_set_hvac_mode_to_salus_client(mock_client, mock_ha_client): +async def test_client_delegates_set_hvac_mode_to_salus_client(mock_client, mock_ha_client): + target = Client(mock_client, mock_ha_client) + + await target.get_state() + + await target.set_hvac_mode(hvac_mode=HVACMode.HEAT) + + mock_client.set_hvac_mode.assert_called_once_with(HVACMode.HEAT) + + +@pytest.mark.asyncio +async def test_client_assumes_hvac_action_as_idle_when_mode_is_off(mock_client, mock_ha_client): + target = Client(mock_client, mock_ha_client) + + await target.get_state() + + await target.set_hvac_mode(hvac_mode=HVACMode.OFF) + + actual = await target.get_state() + + assert actual.action == HVACAction.IDLE + + +@pytest.mark.asyncio +async def test_client_sets_hvac_mode(mock_client, mock_ha_client): + target = Client(mock_client, mock_ha_client) + + await target.get_state() + + await target.set_hvac_mode(hvac_mode=HVACMode.OFF) + + actual = await target.get_state() + + assert actual.mode == HVACMode.OFF + + +@pytest.mark.asyncio +async def test_client_assumes_hvac_action_as_heat_when_mode_is_heat_and_target_temp_is_high(mock_client, mock_ha_client): + target = Client(mock_client, mock_ha_client) + + await target.get_state() + + await target.set_temperature(temperature=30) + await target.set_hvac_mode(hvac_mode=HVACMode.HEAT) + + actual = await target.get_state() + + assert actual.action == HVACAction.HEATING + + +@pytest.mark.asyncio +async def test_client_assumes_hvac_action_as_idle_when_mode_is_heat_and_target_temp_is_low(mock_client, mock_ha_client): + target = Client(mock_client, mock_ha_client) + + await target.get_state() + + await target.set_temperature(temperature=4) + await target.set_hvac_mode(hvac_mode=HVACMode.HEAT) + + actual = await target.get_state() + + assert actual.action == HVACAction.IDLE + + +@pytest.mark.asyncio +async def test_client_assumes_hvac_action_as_heat_when_mode_is_heat_and_target_temp_is_set_high(mock_client, mock_ha_client): target = Client(mock_client, mock_ha_client) + await target.get_state() + await target.set_hvac_mode(hvac_mode=HVACMode.HEAT) + await target.set_temperature(temperature=33) - mock_client.set_hvac_mode.assert_called_once_with(HVACMode.HEAT) \ No newline at end of file + actual = await target.get_state() + + assert actual.action == HVACAction.HEATING + + +@pytest.mark.asyncio +async def test_client_assumes_hvac_action_as_idle_when_mode_is_heat_and_target_temp_is_set_low(mock_client, mock_ha_client): + target = Client(mock_client, mock_ha_client) + + await target.get_state() + + await target.set_hvac_mode(hvac_mode=HVACMode.HEAT) + await target.set_temperature(temperature=4) + + actual = await target.get_state() + + assert actual.action == HVACAction.IDLE \ No newline at end of file From c6ec2cd2dd63a774d664541e31fc8ee78625559f Mon Sep 17 00:00:00 2001 From: Matt Turner Date: Fri, 1 Mar 2024 07:14:47 +0000 Subject: [PATCH 47/55] Add type annotations (#22) --- salusfy/__init__.py | 7 ++----- salusfy/client.py | 17 ++++++++------- salusfy/climate.py | 9 ++++++-- salusfy/ha_temperature_client.py | 2 +- salusfy/simulator/temperature_client.py | 2 +- salusfy/simulator/web_client.py | 6 +++--- salusfy/thermostat_entity.py | 28 ++++++++++++------------- salusfy/web_client.py | 12 +++++------ tests/config_adapter.py | 2 +- 9 files changed, 45 insertions(+), 40 deletions(-) diff --git a/salusfy/__init__.py b/salusfy/__init__.py index 5c63896..6ff4bcd 100644 --- a/salusfy/__init__.py +++ b/salusfy/__init__.py @@ -1,10 +1,7 @@ """The Salus component.""" from .state import State -from .web_client import ( - WebClient, -) - +from .web_client import WebClient from .thermostat_entity import ThermostatEntity -from .client import Client from .ha_temperature_client import HaTemperatureClient +from .client import Client diff --git a/salusfy/client.py b/salusfy/client.py index e5a0152..3e886f3 100644 --- a/salusfy/client.py +++ b/salusfy/client.py @@ -4,12 +4,15 @@ a specialized client. """ import logging -from .web_client import WebClient -from .ha_temperature_client import HaTemperatureClient +from . import ( + WebClient, + HaTemperatureClient, + State, +) from homeassistant.components.climate.const import ( HVACMode, - HVACAction + HVACAction, ) _LOGGER = logging.getLogger(__name__) @@ -24,7 +27,7 @@ def __init__(self, web_client : WebClient, temperature_client : HaTemperatureCli self._temperature_client = temperature_client - async def set_temperature(self, temperature): + async def set_temperature(self, temperature : float) -> None: """Set new target temperature.""" _LOGGER.info("Delegating set_temperature to web client...") @@ -36,7 +39,7 @@ async def set_temperature(self, temperature): self.assume_hvac_action() - async def set_hvac_mode(self, hvac_mode): + async def set_hvac_mode(self, hvac_mode : HVACMode) -> None: """Set HVAC mode, via URL commands.""" _LOGGER.info("Delegating set_hvac_mode to web client...") @@ -48,7 +51,7 @@ async def set_hvac_mode(self, hvac_mode): self.assume_hvac_action() - def assume_hvac_action(self): + def assume_hvac_action(self) -> None: if self._state.mode == HVACMode.OFF: _LOGGER.info("Assuming action is IDLE...") self._state.action = HVACAction.IDLE @@ -63,7 +66,7 @@ def assume_hvac_action(self): self._state.action = HVACAction.IDLE - async def get_state(self): + async def get_state(self) -> State: """Retrieves the status""" if self._state is None: diff --git a/salusfy/climate.py b/salusfy/climate.py index 44bbe1e..5c38a01 100644 --- a/salusfy/climate.py +++ b/salusfy/climate.py @@ -18,7 +18,12 @@ CONF_SIMULATOR = 'simulator' CONF_ENABLE_TEMPERATURE_CLIENT = 'enable_temperature_client' -from . import ( ThermostatEntity, Client, WebClient, HaTemperatureClient ) +from . import ( + ThermostatEntity, + Client, + WebClient, + HaTemperatureClient, +) from . import simulator @@ -64,7 +69,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= ) -def create_client_from(config): +def create_client_from(config) -> Client: username = config.get(CONF_USERNAME) password = config.get(CONF_PASSWORD) id = config.get(CONF_ID) diff --git a/salusfy/ha_temperature_client.py b/salusfy/ha_temperature_client.py index c8a114d..31d79d8 100644 --- a/salusfy/ha_temperature_client.py +++ b/salusfy/ha_temperature_client.py @@ -11,7 +11,7 @@ def __init__(self, host, entity_id, access_token): self._access_token = access_token - async def current_temperature(self): + async def current_temperature(self) -> float: """Gets the current temperature from HA""" url = F"http://{self._host}:8123/api/states/{self._entity_id}" diff --git a/salusfy/simulator/temperature_client.py b/salusfy/simulator/temperature_client.py index 157fd6d..2eed9a6 100644 --- a/salusfy/simulator/temperature_client.py +++ b/salusfy/simulator/temperature_client.py @@ -5,5 +5,5 @@ class TemperatureClient: def __init__(self): pass - async def current_temperature(self): + async def current_temperature(self) -> float: return 15.9 \ No newline at end of file diff --git a/salusfy/simulator/web_client.py b/salusfy/simulator/web_client.py index 0efbbd7..1405553 100644 --- a/salusfy/simulator/web_client.py +++ b/salusfy/simulator/web_client.py @@ -22,7 +22,7 @@ def __init__(self): self._state.frost = 10.1 - async def set_temperature(self, temperature): + async def set_temperature(self, temperature: float) -> None: """Set new target temperature.""" _LOGGER.info("Setting temperature to %.1f...", temperature) @@ -30,7 +30,7 @@ async def set_temperature(self, temperature): self._state.target_temperature = temperature - async def set_hvac_mode(self, hvac_mode): + async def set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set HVAC mode, via URL commands.""" _LOGGER.info("Setting the HVAC mode to %s...", hvac_mode) @@ -38,6 +38,6 @@ async def set_hvac_mode(self, hvac_mode): self._state.mode = hvac_mode - async def get_state(self): + async def get_state(self) -> State: """Retrieves the mock status""" return self._state diff --git a/salusfy/thermostat_entity.py b/salusfy/thermostat_entity.py index a64b67a..a54ea8c 100644 --- a/salusfy/thermostat_entity.py +++ b/salusfy/thermostat_entity.py @@ -35,12 +35,12 @@ def __init__(self, name, client): @property - def supported_features(self): + def supported_features(self) -> ClimateEntityFeature: """Return the list of supported features.""" return ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.TURN_ON | ClimateEntityFeature.TURN_OFF @property - def name(self): + def name(self) -> str: """Return the name of the thermostat.""" return self._name @@ -50,57 +50,57 @@ def unique_id(self) -> str: return "_".join([self._name, "climate"]) @property - def should_poll(self): + def should_poll(self) -> bool: """Return if polling is required.""" return True @property - def min_temp(self): + def min_temp(self) -> float: """Return the minimum temperature.""" return MIN_TEMP @property - def max_temp(self): + def max_temp(self) -> float: """Return the maximum temperature.""" return MAX_TEMP @property - def temperature_unit(self): + def temperature_unit(self) -> UnitOfTemperature: """Return the unit of measurement.""" return UnitOfTemperature.CELSIUS @property - def current_temperature(self): + def current_temperature(self) -> float: """Return the current temperature.""" return self._state.current_temperature @property - def target_temperature(self): + def target_temperature(self) -> float: """Return the temperature we try to reach.""" return self._state.target_temperature @property - def hvac_mode(self): + def hvac_mode(self) -> HVACMode: """Return hvac operation ie. heat, cool mode.""" return self._state.mode @property - def hvac_modes(self): + def hvac_modes(self) -> list[HVACMode]: """HVAC modes.""" return [HVACMode.HEAT, HVACMode.OFF] @property - def hvac_action(self): + def hvac_action(self) -> HVACAction: """Return the current running hvac operation.""" return self._state.action @property - def preset_mode(self): + def preset_mode(self) -> str: """Return the current preset mode, e.g., home, away, temp.""" return PRESET_NONE @@ -124,7 +124,7 @@ async def async_set_temperature(self, **kwargs) -> None: self._state.target_temperature = temperature - async def async_set_hvac_mode(self, hvac_mode) -> None: + async def async_set_hvac_mode(self, hvac_mode : HVACMode) -> None: """Set HVAC mode, via URL commands.""" await self._client.set_hvac_mode(hvac_mode) @@ -140,6 +140,6 @@ async def async_turn_on(self) -> None: await self.async_set_hvac_mode(HVACMode.HEAT) - async def async_update(self): + async def async_update(self) -> None: """Retrieve latest state data.""" self._state = await self._client.get_state() \ No newline at end of file diff --git a/salusfy/web_client.py b/salusfy/web_client.py index be6a732..01fb88b 100644 --- a/salusfy/web_client.py +++ b/salusfy/web_client.py @@ -39,7 +39,7 @@ def __init__(self, username, password, id): self._tokenRetrievedAt = None - async def set_temperature(self, temperature): + async def set_temperature(self, temperature: float) -> None: """Set new target temperature, via URL commands.""" _LOGGER.info("Setting the temperature to %.1f...", temperature) @@ -57,7 +57,7 @@ async def set_temperature(self, temperature): _LOGGER.error("Error Setting the temperature.") - async def set_hvac_mode(self, hvac_mode): + async def set_hvac_mode(self, hvac_mode : HVACMode) -> None: """Set HVAC mode, via URL commands.""" _LOGGER.info("Setting the HVAC mode to %s...", hvac_mode) @@ -80,7 +80,7 @@ async def set_hvac_mode(self, hvac_mode): _LOGGER.error("Error Setting HVAC mode to %s", hvac_mode) - async def obtain_token(self, session): + async def obtain_token(self, session : str) -> str: """Gets the existing session token of the thermostat or retrieves a new one if expired.""" if self._token is None: @@ -97,7 +97,7 @@ async def obtain_token(self, session): return self._token - async def get_token(self, session): + async def get_token(self, session : str) -> None: """Get the Session Token of the Thermostat.""" _LOGGER.info("Getting token from Salus...") @@ -121,7 +121,7 @@ async def get_token(self, session): _LOGGER.error(e) - async def get_state(self): + async def get_state(self) -> State: """Retrieve the current state from the Salus gateway""" _LOGGER.info("Retrieving current state from Salus Gateway...") @@ -148,7 +148,7 @@ async def get_state(self): return state - async def get_state_data(self): + async def get_state_data(self) -> dict: """Retrieves the raw state from the Salus gateway""" _LOGGER.info("Retrieving raw state from Salus Gateway...") diff --git a/tests/config_adapter.py b/tests/config_adapter.py index ef67136..b8c9e80 100644 --- a/tests/config_adapter.py +++ b/tests/config_adapter.py @@ -3,7 +3,7 @@ def __init__(self, config): self._config = config - def get(self, key): + def get(self, key : str) -> any: if (key == 'name'): return 'Simulator' From 5fb904124dd171d698c73ce23cc7c61854feae75 Mon Sep 17 00:00:00 2001 From: Matt Turner Date: Fri, 1 Mar 2024 07:32:20 +0000 Subject: [PATCH 48/55] Fix preset_modes type annotation (#23) --- salusfy/thermostat_entity.py | 45 ++++++++++++++++++------------------ 1 file changed, 22 insertions(+), 23 deletions(-) diff --git a/salusfy/thermostat_entity.py b/salusfy/thermostat_entity.py index a54ea8c..3eb7f40 100644 --- a/salusfy/thermostat_entity.py +++ b/salusfy/thermostat_entity.py @@ -1,10 +1,3 @@ -import logging - -from .web_client import ( - MAX_TEMP, - MIN_TEMP -) - from homeassistant.components.climate.const import ( HVACAction, HVACMode, @@ -17,6 +10,11 @@ UnitOfTemperature, ) +from .web_client import ( + MAX_TEMP, + MIN_TEMP +) + try: from homeassistant.components.climate import ClimateEntity except ImportError: @@ -31,9 +29,10 @@ def __init__(self, name, client): self._name = name self._client = client + self._state = None + self._enable_turn_on_off_backwards_compatibility = False - @property def supported_features(self) -> ClimateEntityFeature: """Return the list of supported features.""" @@ -43,7 +42,7 @@ def supported_features(self) -> ClimateEntityFeature: def name(self) -> str: """Return the name of the thermostat.""" return self._name - + @property def unique_id(self) -> str: """Return the unique ID for this thermostat.""" @@ -86,7 +85,7 @@ def hvac_mode(self) -> HVACMode: return self._state.mode - + @property def hvac_modes(self) -> list[HVACMode]: """HVAC modes.""" @@ -97,7 +96,7 @@ def hvac_modes(self) -> list[HVACMode]: def hvac_action(self) -> HVACAction: """Return the current running hvac operation.""" return self._state.action - + @property def preset_mode(self) -> str: @@ -106,32 +105,32 @@ def preset_mode(self) -> str: @property - def preset_modes(self): + def preset_modes(self) -> list[str]: """Return a list of available preset modes.""" - return ClimateEntityFeature.PRESET_MODE - - + return [PRESET_NONE] + + async def async_set_temperature(self, **kwargs) -> None: """Set new target temperature.""" - + temperature = kwargs.get(ATTR_TEMPERATURE) - + if temperature is None: return - + await self._client.set_temperature(temperature) - + self._state.target_temperature = temperature async def async_set_hvac_mode(self, hvac_mode : HVACMode) -> None: """Set HVAC mode, via URL commands.""" - + await self._client.set_hvac_mode(hvac_mode) self._state.mode = hvac_mode - - + + async def async_turn_off(self) -> None: await self.async_set_hvac_mode(HVACMode.OFF) @@ -142,4 +141,4 @@ async def async_turn_on(self) -> None: async def async_update(self) -> None: """Retrieve latest state data.""" - self._state = await self._client.get_state() \ No newline at end of file + self._state = await self._client.get_state() From 205a5dace6d90dfbc44188aa97060c7c3e7c1a3f Mon Sep 17 00:00:00 2001 From: Matt Turner Date: Fri, 1 Mar 2024 07:48:22 +0000 Subject: [PATCH 49/55] Fix linting errors (#24) --- config.sample.py | 2 +- run.py | 5 +- salusfy/client.py | 31 +++++----- salusfy/climate.py | 58 ++++++++++--------- salusfy/ha_temperature_client.py | 13 ++--- salusfy/simulator/__init__.py | 2 +- salusfy/simulator/temperature_client.py | 4 +- salusfy/simulator/web_client.py | 9 +-- salusfy/state.py | 3 +- salusfy/thermostat_entity.py | 12 +--- salusfy/web_client.py | 75 ++++++++++++++----------- tests/config_adapter.py | 13 ++--- tests/entity_registry.py | 8 +-- tests/mock_config.py | 2 +- tests/test_client.py | 26 +++++---- tests/test_climate.py | 17 +++--- tests/test_thermostat_entity.py | 7 ++- 17 files changed, 149 insertions(+), 138 deletions(-) diff --git a/config.sample.py b/config.sample.py index 4c097ff..b2f819f 100644 --- a/config.sample.py +++ b/config.sample.py @@ -11,4 +11,4 @@ ENABLE_TEMPERATURE_CLIENT = False HOST = "your-home-assistant-ip-address" ENTITY_ID = "sensor.your-temperature-sensor" -ACCESS_TOKEN = "your-HA-access-token" \ No newline at end of file +ACCESS_TOKEN = "your-HA-access-token" diff --git a/run.py b/run.py index de34983..8031fd2 100644 --- a/run.py +++ b/run.py @@ -16,6 +16,7 @@ import logging logging.basicConfig(format='%(levelname)s: %(message)s', level=logging.DEBUG) + async def main(): registry = EntityRegistry() config_adapter = ConfigAdapter(config) @@ -36,7 +37,7 @@ async def main(): print("HVAC Mode: " + thermostat.hvac_mode) -if __name__ == '__main__': +if __name__ == '__main__': loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) - loop.run_until_complete(main()) \ No newline at end of file + loop.run_until_complete(main()) diff --git a/salusfy/client.py b/salusfy/client.py index 3e886f3..f7f4260 100644 --- a/salusfy/client.py +++ b/salusfy/client.py @@ -17,31 +17,33 @@ _LOGGER = logging.getLogger(__name__) + class Client: """Mocks requests to Salus web application""" - def __init__(self, web_client : WebClient, temperature_client : HaTemperatureClient): + def __init__( + self, + web_client: WebClient, + temperature_client: HaTemperatureClient): """Initialize the client.""" self._state = None self._web_client = web_client self._temperature_client = temperature_client - - async def set_temperature(self, temperature : float) -> None: + async def set_temperature(self, temperature: float) -> None: """Set new target temperature.""" - + _LOGGER.info("Delegating set_temperature to web client...") await self._web_client.set_temperature(temperature) self._state.target_temperature = temperature - - self.assume_hvac_action() + self.assume_hvac_action() - async def set_hvac_mode(self, hvac_mode : HVACMode) -> None: + async def set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set HVAC mode, via URL commands.""" - + _LOGGER.info("Delegating set_hvac_mode to web client...") await self._web_client.set_hvac_mode(hvac_mode) @@ -50,30 +52,31 @@ async def set_hvac_mode(self, hvac_mode : HVACMode) -> None: self.assume_hvac_action() - def assume_hvac_action(self) -> None: + """Assumes what the hvac action is based on + the mode and current/target temperatures""" if self._state.mode == HVACMode.OFF: _LOGGER.info("Assuming action is IDLE...") self._state.action = HVACAction.IDLE return if self._state.target_temperature > self._state.current_temperature: - _LOGGER.info("Assuming action is HEATING based on target temperature...") + _LOGGER.info( + "Assuming action is HEATING based on target temperature...") self._state.action = HVACAction.HEATING return _LOGGER.info("Assuming action is IDLE based on target temperature...") self._state.action = HVACAction.IDLE - async def get_state(self) -> State: """Retrieves the status""" - + if self._state is None: _LOGGER.info("Delegating get_state to web client...") self._state = await self._web_client.get_state() - + _LOGGER.info("Updating current temperature from temperature client...") self._state.current_temperature = await self._temperature_client.current_temperature() - return self._state \ No newline at end of file + return self._state diff --git a/salusfy/climate.py b/salusfy/climate.py index 5c38a01..948f760 100644 --- a/salusfy/climate.py +++ b/salusfy/climate.py @@ -1,6 +1,15 @@ """ Adds support for the Salus Thermostat units. """ +from . import ( + ThermostatEntity, + Client, + WebClient, + HaTemperatureClient, +) +from homeassistant.helpers.reload import async_setup_reload_service +from homeassistant.components.climate import PLATFORM_SCHEMA +from . import simulator import logging import homeassistant.helpers.config_validation as cv @@ -18,18 +27,6 @@ CONF_SIMULATOR = 'simulator' CONF_ENABLE_TEMPERATURE_CLIENT = 'enable_temperature_client' -from . import ( - ThermostatEntity, - Client, - WebClient, - HaTemperatureClient, -) - -from . import simulator - -from homeassistant.components.climate import PLATFORM_SCHEMA - -from homeassistant.helpers.reload import async_setup_reload_service __version__ = "0.3.0" @@ -44,17 +41,27 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional( + CONF_NAME, + default=DEFAULT_NAME): cv.string, vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string, vol.Required(CONF_ID): cv.string, - vol.Optional(CONF_SIMULATOR, default=False): cv.boolean, - vol.Optional(CONF_ENABLE_TEMPERATURE_CLIENT, default=False): cv.boolean, - vol.Optional(CONF_ENTITY_ID, default=''): cv.string, - vol.Optional(CONF_ACCESS_TOKEN, default=''): cv.string, - vol.Optional(CONF_HOST, default='localhost'): cv.string - } -) + vol.Optional( + CONF_SIMULATOR, + default=False): cv.boolean, + vol.Optional( + CONF_ENABLE_TEMPERATURE_CLIENT, + default=False): cv.boolean, + vol.Optional( + CONF_ENTITY_ID, + default=''): cv.string, + vol.Optional( + CONF_ACCESS_TOKEN, + default=''): cv.string, + vol.Optional( + CONF_HOST, + default='localhost'): cv.string}) async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): @@ -62,7 +69,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= await async_setup_reload_service(hass, DOMAIN, PLATFORMS) client = create_client_from(config) - + name = config.get(CONF_NAME) async_add_entities( [ThermostatEntity(name, client)], update_before_add=True @@ -81,19 +88,20 @@ def create_client_from(config) -> Client: return Client(simulator.WebClient(), simulator.TemperatureClient()) web_client = WebClient(username, password, id) - + enable_temperature_client = config.get(CONF_ENABLE_TEMPERATURE_CLIENT) - + if not enable_temperature_client: _LOGGER.info('Registering Salus Thermostat client...') return web_client - + entity_id = config.get(CONF_ENTITY_ID) host = config.get(CONF_HOST) access_token = config.get(CONF_ACCESS_TOKEN) - _LOGGER.info('Registering Salus Thermostat client with Temperature client...') + _LOGGER.info( + 'Registering Salus Thermostat client with Temperature client...') ha_client = HaTemperatureClient(host, entity_id, access_token) return Client(web_client, ha_client) diff --git a/salusfy/ha_temperature_client.py b/salusfy/ha_temperature_client.py index 31d79d8..954332d 100644 --- a/salusfy/ha_temperature_client.py +++ b/salusfy/ha_temperature_client.py @@ -4,34 +4,33 @@ """ import aiohttp + class HaTemperatureClient: def __init__(self, host, entity_id, access_token): self._entity_id = entity_id self._host = host self._access_token = access_token - async def current_temperature(self) -> float: """Gets the current temperature from HA""" url = F"http://{self._host}:8123/api/states/{self._entity_id}" - + headers = { "Authorization": F"Bearer {self._access_token}", "Content-Type": "application/json", } - async with aiohttp.ClientSession() as session: async with session.get(url, headers=headers) as response: - + body = await response.json() - + if 'state' not in body: return None - + state = body['state'] if state == 'unavailable': return None - return float(state) \ No newline at end of file + return float(state) diff --git a/salusfy/simulator/__init__.py b/salusfy/simulator/__init__.py index c0f344b..954253c 100644 --- a/salusfy/simulator/__init__.py +++ b/salusfy/simulator/__init__.py @@ -1,2 +1,2 @@ from .temperature_client import TemperatureClient -from .web_client import WebClient \ No newline at end of file +from .web_client import WebClient diff --git a/salusfy/simulator/temperature_client.py b/salusfy/simulator/temperature_client.py index 2eed9a6..2ae6024 100644 --- a/salusfy/simulator/temperature_client.py +++ b/salusfy/simulator/temperature_client.py @@ -1,9 +1,11 @@ """ Adds support for simulating the Salus Thermostats. """ + + class TemperatureClient: def __init__(self): pass async def current_temperature(self) -> float: - return 15.9 \ No newline at end of file + return 15.9 diff --git a/salusfy/simulator/web_client.py b/salusfy/simulator/web_client.py index 1405553..ea99558 100644 --- a/salusfy/simulator/web_client.py +++ b/salusfy/simulator/web_client.py @@ -21,23 +21,20 @@ def __init__(self): self._state.current_temperature = 15.1 self._state.frost = 10.1 - async def set_temperature(self, temperature: float) -> None: """Set new target temperature.""" - + _LOGGER.info("Setting temperature to %.1f...", temperature) - - self._state.target_temperature = temperature + self._state.target_temperature = temperature async def set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set HVAC mode, via URL commands.""" - + _LOGGER.info("Setting the HVAC mode to %s...", hvac_mode) self._state.mode = hvac_mode - async def get_state(self) -> State: """Retrieves the mock status""" return self._state diff --git a/salusfy/state.py b/salusfy/state.py index 1a1152e..82ed0fa 100644 --- a/salusfy/state.py +++ b/salusfy/state.py @@ -1,8 +1,9 @@ class State: """The state of the thermostat.""" + def __init__(self): self.current_temperature = None self.target_temperature = None self.frost = None self.action = None - self.mode = None \ No newline at end of file + self.mode = None diff --git a/salusfy/thermostat_entity.py b/salusfy/thermostat_entity.py index 3eb7f40..877c7d9 100644 --- a/salusfy/thermostat_entity.py +++ b/salusfy/thermostat_entity.py @@ -78,38 +78,32 @@ def target_temperature(self) -> float: """Return the temperature we try to reach.""" return self._state.target_temperature - @property def hvac_mode(self) -> HVACMode: """Return hvac operation ie. heat, cool mode.""" return self._state.mode - @property def hvac_modes(self) -> list[HVACMode]: """HVAC modes.""" return [HVACMode.HEAT, HVACMode.OFF] - @property def hvac_action(self) -> HVACAction: """Return the current running hvac operation.""" return self._state.action - @property def preset_mode(self) -> str: """Return the current preset mode, e.g., home, away, temp.""" return PRESET_NONE - @property def preset_modes(self) -> list[str]: """Return a list of available preset modes.""" return [PRESET_NONE] - async def async_set_temperature(self, **kwargs) -> None: """Set new target temperature.""" @@ -122,23 +116,19 @@ async def async_set_temperature(self, **kwargs) -> None: self._state.target_temperature = temperature - - async def async_set_hvac_mode(self, hvac_mode : HVACMode) -> None: + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set HVAC mode, via URL commands.""" await self._client.set_hvac_mode(hvac_mode) self._state.mode = hvac_mode - async def async_turn_off(self) -> None: await self.async_set_hvac_mode(HVACMode.OFF) - async def async_turn_on(self) -> None: await self.async_set_hvac_mode(HVACMode.HEAT) - async def async_update(self) -> None: """Retrieve latest state data.""" self._state = await self._client.get_state() diff --git a/salusfy/web_client.py b/salusfy/web_client.py index 01fb88b..7d47e54 100644 --- a/salusfy/web_client.py +++ b/salusfy/web_client.py @@ -27,6 +27,7 @@ MAX_TEMP = 34.5 MAX_TOKEN_AGE_SECONDS = 60 * 10 + class WebClient: """Adapter around Salus IT500 web application.""" @@ -38,7 +39,6 @@ def __init__(self, username, password, id): self._token = None self._tokenRetrievedAt = None - async def set_temperature(self, temperature: float) -> None: """Set new target temperature, via URL commands.""" @@ -47,21 +47,25 @@ async def set_temperature(self, temperature: float) -> None: async with aiohttp.ClientSession() as session: token = await self.obtain_token(session) - payload = {"token": token, "devId": self._id, "tempUnit": "0", "current_tempZ1_set": "1", "current_tempZ1": temperature} + payload = { + "token": token, + "devId": self._id, + "tempUnit": "0", + "current_tempZ1_set": "1", + "current_tempZ1": temperature} headers = {"Content-Type": "application/x-www-form-urlencoded"} - + try: await session.post(URL_SET_DATA, data=payload, headers=headers) _LOGGER.info("Salusfy set_temperature: OK") - except: + except BaseException: _LOGGER.error("Error Setting the temperature.") - - async def set_hvac_mode(self, hvac_mode : HVACMode) -> None: + async def set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set HVAC mode, via URL commands.""" - + _LOGGER.info("Setting the HVAC mode to %s...", hvac_mode) - + headers = {"Content-Type": "application/x-www-form-urlencoded"} auto = "1" @@ -69,18 +73,21 @@ async def set_hvac_mode(self, hvac_mode : HVACMode) -> None: auto = "1" elif hvac_mode == HVACMode.HEAT: auto = "0" - + async with aiohttp.ClientSession() as session: token = await self.obtain_token(session) - payload = {"token": token, "devId": self._id, "auto": auto, "auto_setZ1": "1"} + payload = { + "token": token, + "devId": self._id, + "auto": auto, + "auto_setZ1": "1"} try: await session.post(URL_SET_DATA, data=payload, headers=headers) - except: + except BaseException: _LOGGER.error("Error Setting HVAC mode to %s", hvac_mode) - - async def obtain_token(self, session : str) -> str: + async def obtain_token(self, session: str) -> str: """Gets the existing session token of the thermostat or retrieves a new one if expired.""" if self._token is None: @@ -91,26 +98,30 @@ async def obtain_token(self, session : str) -> str: if self._tokenRetrievedAt > time.time() - MAX_TOKEN_AGE_SECONDS: _LOGGER.info("Using cached token...") return self._token - + _LOGGER.info("Token has expired, getting new one...") await self.get_token(session) return self._token - - async def get_token(self, session : str) -> None: + async def get_token(self, session: str) -> None: """Get the Session Token of the Thermostat.""" _LOGGER.info("Getting token from Salus...") - payload = {"IDemail": self._username, "password": self._password, "login": "Login", "keep_logged_in": "1"} + payload = { + "IDemail": self._username, + "password": self._password, + "login": "Login", + "keep_logged_in": "1"} headers = {"Content-Type": "application/x-www-form-urlencoded"} - + try: await session.post(URL_LOGIN, data=payload, headers=headers) params = {"devId": self._id} getTkoken = await session.get(URL_GET_TOKEN, params=params) body = await getTkoken.text() - result = re.search('', body) + result = re.search( + '', body) _LOGGER.info("Salusfy get_token OK") self._token = result.group(1) self._tokenRetrievedAt = time.time() @@ -120,7 +131,6 @@ async def get_token(self, session : str) -> None: _LOGGER.error("Error getting the session token.") _LOGGER.error(e) - async def get_state(self) -> State: """Retrieve the current state from the Salus gateway""" @@ -132,21 +142,20 @@ async def get_state(self) -> State: state.target_temperature = float(data["CH1currentSetPoint"]) state.current_temperature = float(data["CH1currentRoomTemp"]) state.frost = float(data["frost"]) - + status = data['CH1heatOnOffStatus'] if status == "1": state.action = HVACAction.HEATING else: state.action = HVACAction.IDLE - + mode = data['CH1heatOnOff'] if mode == "1": state.mode = HVACMode.OFF else: state.mode = HVACMode.HEAT - - return state + return state async def get_state_data(self) -> dict: """Retrieves the raw state from the Salus gateway""" @@ -155,19 +164,21 @@ async def get_state_data(self) -> dict: async with aiohttp.ClientSession() as session: token = await self.obtain_token(session) - - params = {"devId": self._id, "token": token, "&_": str(int(round(time.time() * 1000)))} + + params = {"devId": self._id, "token": token, + "&_": str(int(round(time.time() * 1000)))} try: - r = await session.get(url = URL_GET_DATA, params = params) + r = await session.get(url=URL_GET_DATA, params=params) if not r: _LOGGER.error("Could not get data from Salus.") return None - except: - _LOGGER.error("Error Getting the data from Web. Please check the connection to salus-it500.com manually.") + except BaseException: + _LOGGER.error( + "Error Getting the data from Web. Please check the connection to salus-it500.com manually.") return None - + body = await r.text() - _LOGGER.info("Salusfy get_data output " + body) + _LOGGER.info("Salusfy get_data output %s", body) data = json.loads(body) - return data \ No newline at end of file + return data diff --git a/tests/config_adapter.py b/tests/config_adapter.py index b8c9e80..07bf356 100644 --- a/tests/config_adapter.py +++ b/tests/config_adapter.py @@ -2,14 +2,13 @@ class ConfigAdapter: def __init__(self, config): self._config = config - - def get(self, key : str) -> any: + def get(self, key: str) -> any: if (key == 'name'): return 'Simulator' - + if (key == 'id'): return self._config.DEVICE_ID - + if (key == 'username'): return self._config.USERNAME @@ -20,12 +19,12 @@ def get(self, key : str) -> any: if hasattr(self._config, 'SIMULATOR'): return self._config.SIMULATOR return False - + if (key == 'enable_temperature_client'): if hasattr(self._config, 'ENABLE_TEMPERATURE_CLIENT'): return self._config.ENABLE_TEMPERATURE_CLIENT return False - + if (key == 'host'): return self._config.HOST @@ -33,4 +32,4 @@ def get(self, key : str) -> any: return self._config.ENTITY_ID if (key == 'access_token'): - return self._config.ACCESS_TOKEN \ No newline at end of file + return self._config.ACCESS_TOKEN diff --git a/tests/entity_registry.py b/tests/entity_registry.py index 7f4fbb1..9fe1534 100644 --- a/tests/entity_registry.py +++ b/tests/entity_registry.py @@ -2,23 +2,19 @@ class EntityRegistry: def __init__(self): self._entities = [] self._update_before_add = False - - + def register(self, list, **kwargs): self._update_before_add = kwargs.get('update_before_add') self._entities.extend(list) - @property def entities(self): return self._entities - @property def first(self): return self._entities[0] - @property def update_before_add(self): - return self._update_before_add \ No newline at end of file + return self._update_before_add diff --git a/tests/mock_config.py b/tests/mock_config.py index 0125a04..3c2a44d 100644 --- a/tests/mock_config.py +++ b/tests/mock_config.py @@ -5,4 +5,4 @@ ACCESS_TOKEN = "some-secret" SIMULATOR = True ENABLE_TEMPERATURE_CLIENT = True -HOST = "192.168.0.99" \ No newline at end of file +HOST = "192.168.0.99" diff --git a/tests/test_client.py b/tests/test_client.py index 4ab333d..18df14f 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,18 +1,20 @@ -import pytest from unittest.mock import Mock +import pytest -from salusfy import ( Client, State, WebClient, HaTemperatureClient ) from homeassistant.components.climate.const import ( HVACMode, HVACAction ) +from salusfy import (Client, State, WebClient, HaTemperatureClient) + + @pytest.fixture def mock_client(): state = State() state.current_temperature = 15.3 state.target_temperature = 33.3 - + mock = Mock(WebClient) mock.get_state.return_value = state @@ -24,7 +26,7 @@ def mock_ha_client(): mock = Mock(HaTemperatureClient) mock.current_temperature.return_value = 21.1 - + return mock @@ -42,7 +44,7 @@ async def test_client_returns_target_temp_from_home_assistant_client(mock_client target = Client(mock_client, mock_ha_client) actual = await target.get_state() - + assert actual.current_temperature == 21.1 @@ -90,7 +92,7 @@ async def test_client_assumes_hvac_action_as_idle_when_mode_is_off(mock_client, await target.set_hvac_mode(hvac_mode=HVACMode.OFF) actual = await target.get_state() - + assert actual.action == HVACAction.IDLE @@ -103,7 +105,7 @@ async def test_client_sets_hvac_mode(mock_client, mock_ha_client): await target.set_hvac_mode(hvac_mode=HVACMode.OFF) actual = await target.get_state() - + assert actual.mode == HVACMode.OFF @@ -117,7 +119,7 @@ async def test_client_assumes_hvac_action_as_heat_when_mode_is_heat_and_target_t await target.set_hvac_mode(hvac_mode=HVACMode.HEAT) actual = await target.get_state() - + assert actual.action == HVACAction.HEATING @@ -131,7 +133,7 @@ async def test_client_assumes_hvac_action_as_idle_when_mode_is_heat_and_target_t await target.set_hvac_mode(hvac_mode=HVACMode.HEAT) actual = await target.get_state() - + assert actual.action == HVACAction.IDLE @@ -145,7 +147,7 @@ async def test_client_assumes_hvac_action_as_heat_when_mode_is_heat_and_target_t await target.set_temperature(temperature=33) actual = await target.get_state() - + assert actual.action == HVACAction.HEATING @@ -159,5 +161,5 @@ async def test_client_assumes_hvac_action_as_idle_when_mode_is_heat_and_target_t await target.set_temperature(temperature=4) actual = await target.get_state() - - assert actual.action == HVACAction.IDLE \ No newline at end of file + + assert actual.action == HVACAction.IDLE diff --git a/tests/test_climate.py b/tests/test_climate.py index 1c2a22c..4b44d99 100644 --- a/tests/test_climate.py +++ b/tests/test_climate.py @@ -6,14 +6,15 @@ from . import mock_config + class MockHass: @property def services(self): return self - + def has_service(self, domain, service): return False - + def async_register(self, domain, service, admin_handler, schema): pass @@ -29,15 +30,15 @@ async def setup_climate_platform(): @pytest.mark.asyncio async def test_entity_is_registered(): registry = await setup_climate_platform() - + assert len(registry.entities) == 1 @pytest.mark.asyncio async def test_entity_is_updated_before_added(): registry = await setup_climate_platform() - - assert registry.update_before_add == True + + assert registry.update_before_add @pytest.mark.asyncio @@ -47,7 +48,7 @@ async def test_entity_returns_mock_temperature(): thermostat = registry.first await thermostat.async_update() - + assert thermostat.current_temperature == 15.9 @@ -58,5 +59,5 @@ async def test_entity_returns_mock_target_temperature(): thermostat = registry.first await thermostat.async_update() - - assert thermostat.target_temperature == 20.1 \ No newline at end of file + + assert thermostat.target_temperature == 20.1 diff --git a/tests/test_thermostat_entity.py b/tests/test_thermostat_entity.py index 57a2afb..61bf5db 100644 --- a/tests/test_thermostat_entity.py +++ b/tests/test_thermostat_entity.py @@ -1,17 +1,18 @@ import pytest from unittest.mock import Mock -from salusfy import ( ThermostatEntity, State, WebClient ) +from salusfy import (ThermostatEntity, State, WebClient) from homeassistant.components.climate.const import ( HVACMode ) + @pytest.fixture def mock_client(): state = State() state.current_temperature = 15.3 state.target_temperature = 33.3 - + mock = Mock(WebClient) mock.get_state.return_value = state @@ -48,4 +49,4 @@ async def test_entity_delegates_set_hvac_mode_to_web_client(mock_client): await target.async_set_hvac_mode(hvac_mode=HVACMode.HEAT) mock_client.set_hvac_mode.assert_called_once_with(HVACMode.HEAT) - assert target.hvac_mode == HVACMode.HEAT \ No newline at end of file + assert target.hvac_mode == HVACMode.HEAT From f38066fd88e751197ac931264c4f27026564a68e Mon Sep 17 00:00:00 2001 From: Matt Turner Date: Fri, 1 Mar 2024 07:52:55 +0000 Subject: [PATCH 50/55] Add format command (#25) --- format.ps1 | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 format.ps1 diff --git a/format.ps1 b/format.ps1 new file mode 100644 index 0000000..3b78584 --- /dev/null +++ b/format.ps1 @@ -0,0 +1,3 @@ +autopep8 --in-place --aggressive --aggressive -r . + +pylint **/*.py \ No newline at end of file From 6ccd7dfa7300d91aedf64d66ce8488c709e667b3 Mon Sep 17 00:00:00 2001 From: Matt Turner Date: Fri, 1 Mar 2024 08:14:13 +0000 Subject: [PATCH 51/55] Fix more linting errors (#26) --- README.md | 3 +++ run.py | 6 ++++-- salusfy/web_client.py | 21 ++++++++++----------- tests/config_adapter.py | 22 +++++++++++++--------- 4 files changed, 30 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index b398621..f3d5397 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,7 @@ # Home-Assistant Custom Components + +[![CI](https://github.com/matthewturner/salusfy/actions/workflows/ci.yml/badge.svg)](https://github.com/matthewturner/salusfy/actions/workflows/ci.yml) + Custom Components for Home-Assistant (http://www.home-assistant.io) # Salus Thermostat Climate Component diff --git a/run.py b/run.py index 8031fd2..ab88278 100644 --- a/run.py +++ b/run.py @@ -4,16 +4,18 @@ # 3 Run with `python run.py` import asyncio +import logging + +from homeassistant.components.climate.const import HVACMode + from salusfy import climate from tests.test_climate import MockHass from tests.config_adapter import ConfigAdapter from tests.entity_registry import EntityRegistry -from homeassistant.components.climate.const import HVACMode import config -import logging logging.basicConfig(format='%(levelname)s: %(message)s', level=logging.DEBUG) diff --git a/salusfy/web_client.py b/salusfy/web_client.py index 7d47e54..7e4b878 100644 --- a/salusfy/web_client.py +++ b/salusfy/web_client.py @@ -4,16 +4,15 @@ import time import logging import re -import aiohttp import json - -from .state import State +import aiohttp from homeassistant.components.climate.const import ( HVACMode, HVACAction, ) +from .state import State _LOGGER = logging.getLogger(__name__) @@ -31,13 +30,13 @@ class WebClient: """Adapter around Salus IT500 web application.""" - def __init__(self, username, password, id): + def __init__(self, username: str, password: str, device_id: str): """Initialize the client.""" self._username = username self._password = password - self._id = id + self._id = device_id self._token = None - self._tokenRetrievedAt = None + self._token_retrieved_at = None async def set_temperature(self, temperature: float) -> None: """Set new target temperature, via URL commands.""" @@ -95,7 +94,7 @@ async def obtain_token(self, session: str) -> str: await self.get_token(session) return self._token - if self._tokenRetrievedAt > time.time() - MAX_TOKEN_AGE_SECONDS: + if self._token_retrieved_at > time.time() - MAX_TOKEN_AGE_SECONDS: _LOGGER.info("Using cached token...") return self._token @@ -118,16 +117,16 @@ async def get_token(self, session: str) -> None: try: await session.post(URL_LOGIN, data=payload, headers=headers) params = {"devId": self._id} - getTkoken = await session.get(URL_GET_TOKEN, params=params) - body = await getTkoken.text() + token_response = await session.get(URL_GET_TOKEN, params=params) + body = await token_response.text() result = re.search( '', body) _LOGGER.info("Salusfy get_token OK") self._token = result.group(1) - self._tokenRetrievedAt = time.time() + self._token_retrieved_at = time.time() except Exception as e: self._token = None - self._tokenRetrievedAt = None + self._token_retrieved_at = None _LOGGER.error("Error getting the session token.") _LOGGER.error(e) diff --git a/tests/config_adapter.py b/tests/config_adapter.py index 07bf356..788cfaa 100644 --- a/tests/config_adapter.py +++ b/tests/config_adapter.py @@ -1,35 +1,39 @@ class ConfigAdapter: + """Simulates how Home Assistant loads configuration""" + def __init__(self, config): self._config = config def get(self, key: str) -> any: - if (key == 'name'): + """Returns the config value based on the Home Assistant key""" + + if key == 'name': return 'Simulator' - if (key == 'id'): + if key == 'id': return self._config.DEVICE_ID - if (key == 'username'): + if key == 'username': return self._config.USERNAME - if (key == 'password'): + if key == 'password': return self._config.PASSWORD - if (key == 'simulator'): + if key == 'simulator': if hasattr(self._config, 'SIMULATOR'): return self._config.SIMULATOR return False - if (key == 'enable_temperature_client'): + if key == 'enable_temperature_client': if hasattr(self._config, 'ENABLE_TEMPERATURE_CLIENT'): return self._config.ENABLE_TEMPERATURE_CLIENT return False - if (key == 'host'): + if key == 'host': return self._config.HOST - if (key == 'entity_id'): + if key == 'entity_id': return self._config.ENTITY_ID - if (key == 'access_token'): + if key == 'access_token': return self._config.ACCESS_TOKEN From 36337a5d9a455d85d9ffff174ac2b3503df895d2 Mon Sep 17 00:00:00 2001 From: Matt Turner Date: Fri, 1 Mar 2024 19:47:50 +0000 Subject: [PATCH 52/55] Change state to dataclass (#27) --- run.py | 24 +++++++++++++++++++----- salusfy/client.py | 11 ++++++----- salusfy/climate.py | 28 +++++++++++++++++----------- salusfy/ha_temperature_client.py | 10 +++++----- salusfy/state.py | 17 ++++++++++------- salusfy/thermostat_entity.py | 4 +++- tests/test_thermostat_entity.py | 6 ++---- 7 files changed, 62 insertions(+), 38 deletions(-) diff --git a/run.py b/run.py index ab88278..7c57fbe 100644 --- a/run.py +++ b/run.py @@ -1,3 +1,5 @@ +"""Exercises the Salus client as if it was run through Home Assistant""" + # To run this script to test the component: # 1 Copy config.sample.py to config.py # 2 Replace the username/password/deviceid (don't worry, this file will be ignored by git) @@ -6,6 +8,8 @@ import asyncio import logging +import argparse + from homeassistant.components.climate.const import HVACMode from salusfy import climate @@ -19,19 +23,23 @@ logging.basicConfig(format='%(levelname)s: %(message)s', level=logging.DEBUG) -async def main(): +async def main(set_temp: bool) -> None: + """Exercises the Salus client""" + registry = EntityRegistry() config_adapter = ConfigAdapter(config) - await climate.async_setup_platform(MockHass(), config_adapter, async_add_entities=registry.register, discovery_info=None) + await climate.async_setup_platform( + MockHass(), config_adapter, async_add_entities=registry.register, discovery_info=None) thermostat = registry.first await thermostat.async_update() await thermostat.async_update() - await thermostat.async_set_hvac_mode(HVACMode.HEAT) - await thermostat.async_set_temperature(temperature=9.8) + if set_temp: + await thermostat.async_set_hvac_mode(HVACMode.HEAT) + await thermostat.async_set_temperature(temperature=9.8) print("Current: " + str(thermostat.current_temperature)) print("Target: " + str(thermostat.target_temperature)) @@ -40,6 +48,12 @@ async def main(): if __name__ == '__main__': + parser = argparse.ArgumentParser("Salus Client Simulator") + parser.add_argument("--set_temp", default=False, required=False, + help="Determines whether to set the temp", + type=bool) + args = parser.parse_args() + loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) - loop.run_until_complete(main()) + loop.run_until_complete(main(args.set_temp)) diff --git a/salusfy/client.py b/salusfy/client.py index f7f4260..47d115c 100644 --- a/salusfy/client.py +++ b/salusfy/client.py @@ -4,17 +4,18 @@ a specialized client. """ import logging -from . import ( - WebClient, - HaTemperatureClient, - State, -) from homeassistant.components.climate.const import ( HVACMode, HVACAction, ) +from . import ( + WebClient, + HaTemperatureClient, + State, +) + _LOGGER = logging.getLogger(__name__) diff --git a/salusfy/climate.py b/salusfy/climate.py index 948f760..101022f 100644 --- a/salusfy/climate.py +++ b/salusfy/climate.py @@ -1,19 +1,12 @@ """ Adds support for the Salus Thermostat units. """ -from . import ( - ThermostatEntity, - Client, - WebClient, - HaTemperatureClient, -) +import logging from homeassistant.helpers.reload import async_setup_reload_service from homeassistant.components.climate import PLATFORM_SCHEMA -from . import simulator -import logging +import voluptuous as vol import homeassistant.helpers.config_validation as cv -import voluptuous as vol from homeassistant.const import ( CONF_PASSWORD, @@ -24,6 +17,14 @@ CONF_HOST ) +from . import simulator +from . import ( + ThermostatEntity, + Client, + WebClient, + HaTemperatureClient, +) + CONF_SIMULATOR = 'simulator' CONF_ENABLE_TEMPERATURE_CLIENT = 'enable_temperature_client' @@ -66,6 +67,9 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the E-Thermostat platform.""" + + _LOGGER.info("Discovery info: %s", discovery_info) + await async_setup_reload_service(hass, DOMAIN, PLATFORMS) client = create_client_from(config) @@ -77,9 +81,11 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= def create_client_from(config) -> Client: + """Creates a client object based on the specified configuration""" + username = config.get(CONF_USERNAME) password = config.get(CONF_PASSWORD) - id = config.get(CONF_ID) + device_id = config.get(CONF_ID) enable_simulator = config.get(CONF_SIMULATOR) if enable_simulator: @@ -87,7 +93,7 @@ def create_client_from(config) -> Client: return Client(simulator.WebClient(), simulator.TemperatureClient()) - web_client = WebClient(username, password, id) + web_client = WebClient(username, password, device_id) enable_temperature_client = config.get(CONF_ENABLE_TEMPERATURE_CLIENT) diff --git a/salusfy/ha_temperature_client.py b/salusfy/ha_temperature_client.py index 954332d..f82e39d 100644 --- a/salusfy/ha_temperature_client.py +++ b/salusfy/ha_temperature_client.py @@ -1,11 +1,11 @@ -""" -Retrieves the current temperature from -another entity from the Home Assistant API -""" +"""Reduces reliance on the Salus API""" import aiohttp - class HaTemperatureClient: + """ + Retrieves the current temperature from + another entity from the Home Assistant API + """ def __init__(self, host, entity_id, access_token): self._entity_id = entity_id self._host = host diff --git a/salusfy/state.py b/salusfy/state.py index 82ed0fa..3a4dfcd 100644 --- a/salusfy/state.py +++ b/salusfy/state.py @@ -1,9 +1,12 @@ +"""Exposes state of the thermostat.""" + +import dataclasses + +@dataclasses.dataclass class State: """The state of the thermostat.""" - - def __init__(self): - self.current_temperature = None - self.target_temperature = None - self.frost = None - self.action = None - self.mode = None + current_temperature = None + target_temperature = None + frost = None + action = None + mode = None diff --git a/salusfy/thermostat_entity.py b/salusfy/thermostat_entity.py index 877c7d9..b2cdac9 100644 --- a/salusfy/thermostat_entity.py +++ b/salusfy/thermostat_entity.py @@ -36,7 +36,9 @@ def __init__(self, name, client): @property def supported_features(self) -> ClimateEntityFeature: """Return the list of supported features.""" - return ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.TURN_ON | ClimateEntityFeature.TURN_OFF + return (ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_ON + | ClimateEntityFeature.TURN_OFF) @property def name(self) -> str: diff --git a/tests/test_thermostat_entity.py b/tests/test_thermostat_entity.py index 61bf5db..5a61d0c 100644 --- a/tests/test_thermostat_entity.py +++ b/tests/test_thermostat_entity.py @@ -1,10 +1,8 @@ -import pytest from unittest.mock import Mock +from homeassistant.components.climate.const import HVACMode +import pytest from salusfy import (ThermostatEntity, State, WebClient) -from homeassistant.components.climate.const import ( - HVACMode -) @pytest.fixture From 86df172477c36c0a6db81df6b11f6ed2c281007e Mon Sep 17 00:00:00 2001 From: Matt Turner Date: Fri, 1 Mar 2024 20:47:58 +0000 Subject: [PATCH 53/55] Report auto status accurately (#28) --- salusfy/web_client.py | 17 +++++++--- tests/test_web_client.py | 67 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 4 deletions(-) create mode 100644 tests/test_web_client.py diff --git a/salusfy/web_client.py b/salusfy/web_client.py index 7e4b878..a9d707b 100644 --- a/salusfy/web_client.py +++ b/salusfy/web_client.py @@ -137,6 +137,11 @@ async def get_state(self) -> State: data = await self.get_state_data() + return WebClient.convert_to_state(data) + + @classmethod + def convert_to_state(cls, data: dict) -> State: + """Converts the data payload to a state object""" state = State() state.target_temperature = float(data["CH1currentSetPoint"]) state.current_temperature = float(data["CH1currentRoomTemp"]) @@ -148,11 +153,15 @@ async def get_state(self) -> State: else: state.action = HVACAction.IDLE - mode = data['CH1heatOnOff'] - if mode == "1": - state.mode = HVACMode.OFF + heat_on_off = data['CH1heatOnOff'] + auto_mode = data['CH1autoMode'] + if heat_on_off == "0" and auto_mode == "0": + state.mode = HVACMode.AUTO else: - state.mode = HVACMode.HEAT + if heat_on_off == "1": + state.mode = HVACMode.OFF + else: + state.mode = HVACMode.HEAT return state diff --git a/tests/test_web_client.py b/tests/test_web_client.py new file mode 100644 index 0000000..5be3a3d --- /dev/null +++ b/tests/test_web_client.py @@ -0,0 +1,67 @@ +import pytest + +from homeassistant.components.climate.const import ( + HVACMode, + HVACAction +) + +from salusfy import WebClient + +@pytest.fixture +def payload() -> dict: + """Returns the default data for the tests""" + return { + 'CH1currentSetPoint': 20.1, + 'CH1currentRoomTemp': 15.2, + 'frost': 8.5, + 'CH1heatOnOffStatus': "1", + 'CH1heatOnOff': "1", + 'CH1autoMode': "1" + } + +def test_extract_target_temperature(payload): + actual = WebClient.convert_to_state(payload) + + assert actual.target_temperature == 20.1 + +def test_extract_current_temperature(payload): + actual = WebClient.convert_to_state(payload) + + assert actual.current_temperature == 15.2 + +def test_extract_frost(payload): + actual = WebClient.convert_to_state(payload) + + assert actual.frost == 8.5 + +def test_hvac_action_is_heating(payload): + payload['CH1heatOnOffStatus'] = "1" + payload['CH1heatOnOff'] = "1" + payload['CH1autoMode'] = "1" + actual = WebClient.convert_to_state(payload) + + assert actual.action == HVACAction.HEATING + +def test_hvac_action_is_off(payload): + payload['CH1heatOnOffStatus'] = "0" + payload['CH1heatOnOff'] = "1" + payload['CH1autoMode'] = "1" + actual = WebClient.convert_to_state(payload) + + assert actual.action == HVACAction.IDLE + +def test_hvac_mode_is_off(payload): + payload['CH1heatOnOffStatus'] = "1" + payload['CH1heatOnOff'] = "1" + payload['CH1autoMode'] = "1" + actual = WebClient.convert_to_state(payload) + + assert actual.mode == HVACMode.OFF + +def test_hvac_mode_is_heat(payload): + payload['CH1heatOnOffStatus'] = "1" + payload['CH1heatOnOff'] = "0" + payload['CH1autoMode'] = "1" + actual = WebClient.convert_to_state(payload) + + assert actual.mode == HVACMode.HEAT From fcdb1bcb9f186cb453c9ed8eceafaa8bf03ada50 Mon Sep 17 00:00:00 2001 From: Matt Turner Date: Fri, 1 Mar 2024 20:50:51 +0000 Subject: [PATCH 54/55] Fix linting errors (#29) --- salusfy/climate.py | 2 +- salusfy/ha_temperature_client.py | 2 ++ salusfy/state.py | 1 + tests/test_web_client.py | 8 ++++++++ 4 files changed, 12 insertions(+), 1 deletion(-) diff --git a/salusfy/climate.py b/salusfy/climate.py index 101022f..3fd3c6b 100644 --- a/salusfy/climate.py +++ b/salusfy/climate.py @@ -82,7 +82,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= def create_client_from(config) -> Client: """Creates a client object based on the specified configuration""" - + username = config.get(CONF_USERNAME) password = config.get(CONF_PASSWORD) device_id = config.get(CONF_ID) diff --git a/salusfy/ha_temperature_client.py b/salusfy/ha_temperature_client.py index f82e39d..664a683 100644 --- a/salusfy/ha_temperature_client.py +++ b/salusfy/ha_temperature_client.py @@ -1,11 +1,13 @@ """Reduces reliance on the Salus API""" import aiohttp + class HaTemperatureClient: """ Retrieves the current temperature from another entity from the Home Assistant API """ + def __init__(self, host, entity_id, access_token): self._entity_id = entity_id self._host = host diff --git a/salusfy/state.py b/salusfy/state.py index 3a4dfcd..f2924de 100644 --- a/salusfy/state.py +++ b/salusfy/state.py @@ -2,6 +2,7 @@ import dataclasses + @dataclasses.dataclass class State: """The state of the thermostat.""" diff --git a/tests/test_web_client.py b/tests/test_web_client.py index 5be3a3d..d3981da 100644 --- a/tests/test_web_client.py +++ b/tests/test_web_client.py @@ -7,6 +7,7 @@ from salusfy import WebClient + @pytest.fixture def payload() -> dict: """Returns the default data for the tests""" @@ -19,21 +20,25 @@ def payload() -> dict: 'CH1autoMode': "1" } + def test_extract_target_temperature(payload): actual = WebClient.convert_to_state(payload) assert actual.target_temperature == 20.1 + def test_extract_current_temperature(payload): actual = WebClient.convert_to_state(payload) assert actual.current_temperature == 15.2 + def test_extract_frost(payload): actual = WebClient.convert_to_state(payload) assert actual.frost == 8.5 + def test_hvac_action_is_heating(payload): payload['CH1heatOnOffStatus'] = "1" payload['CH1heatOnOff'] = "1" @@ -42,6 +47,7 @@ def test_hvac_action_is_heating(payload): assert actual.action == HVACAction.HEATING + def test_hvac_action_is_off(payload): payload['CH1heatOnOffStatus'] = "0" payload['CH1heatOnOff'] = "1" @@ -50,6 +56,7 @@ def test_hvac_action_is_off(payload): assert actual.action == HVACAction.IDLE + def test_hvac_mode_is_off(payload): payload['CH1heatOnOffStatus'] = "1" payload['CH1heatOnOff'] = "1" @@ -58,6 +65,7 @@ def test_hvac_mode_is_off(payload): assert actual.mode == HVACMode.OFF + def test_hvac_mode_is_heat(payload): payload['CH1heatOnOffStatus'] = "1" payload['CH1heatOnOff'] = "0" From 2070cc8c49d1b088e5a982e3fed0432e4755bf4b Mon Sep 17 00:00:00 2001 From: Matt Turner Date: Sat, 2 Mar 2024 14:44:17 +0000 Subject: [PATCH 55/55] Fix linting errors (#30) --- .pylintrc | 6 ++++++ salusfy/ha_temperature_client.py | 2 ++ salusfy/web_client.py | 2 +- tests/config_adapter.py | 5 +++++ tests/entity_registry.py | 13 +++++++++++-- tests/test_client.py | 26 ++++++++++++++++++-------- tests/test_climate.py | 13 +++++++++++-- tests/test_thermostat_entity.py | 12 +++++++----- tests/test_web_client.py | 6 ++++-- 9 files changed, 65 insertions(+), 20 deletions(-) create mode 100644 .pylintrc diff --git a/.pylintrc b/.pylintrc new file mode 100644 index 0000000..eaea632 --- /dev/null +++ b/.pylintrc @@ -0,0 +1,6 @@ + [MAIN] + ignore-paths= + config.py + +disable= + missing-module-docstring \ No newline at end of file diff --git a/salusfy/ha_temperature_client.py b/salusfy/ha_temperature_client.py index 664a683..f97c685 100644 --- a/salusfy/ha_temperature_client.py +++ b/salusfy/ha_temperature_client.py @@ -1,6 +1,8 @@ """Reduces reliance on the Salus API""" import aiohttp +# pylint: disable=too-few-public-methods + class HaTemperatureClient: """ diff --git a/salusfy/web_client.py b/salusfy/web_client.py index a9d707b..fbc7270 100644 --- a/salusfy/web_client.py +++ b/salusfy/web_client.py @@ -182,7 +182,7 @@ async def get_state_data(self) -> dict: return None except BaseException: _LOGGER.error( - "Error Getting the data from Web. Please check the connection to salus-it500.com manually.") + "Error Getting the data from Salus. Check the connection to salus-it500.com.") return None body = await r.text() diff --git a/tests/config_adapter.py b/tests/config_adapter.py index 788cfaa..5c30d22 100644 --- a/tests/config_adapter.py +++ b/tests/config_adapter.py @@ -1,3 +1,6 @@ +# pylint: disable=too-few-public-methods +# pylint: disable=too-many-return-statements + class ConfigAdapter: """Simulates how Home Assistant loads configuration""" @@ -37,3 +40,5 @@ def get(self, key: str) -> any: if key == 'access_token': return self._config.ACCESS_TOKEN + + return 'Unknown' diff --git a/tests/entity_registry.py b/tests/entity_registry.py index 9fe1534..33539ca 100644 --- a/tests/entity_registry.py +++ b/tests/entity_registry.py @@ -1,20 +1,29 @@ class EntityRegistry: + """Registry used for local and test executions.""" + def __init__(self): self._entities = [] self._update_before_add = False - def register(self, list, **kwargs): + def register(self, entities, **kwargs): + """Registers the list of entities.""" self._update_before_add = kwargs.get('update_before_add') - self._entities.extend(list) + self._entities.extend(entities) @property def entities(self): + """Returns the list of entries registered during configuration.""" return self._entities @property def first(self): + """Returns the first entity registered.""" return self._entities[0] @property def update_before_add(self): + """ + Determines whether the update_before_add value + has been set during configuration. + """ return self._update_before_add diff --git a/tests/test_client.py b/tests/test_client.py index 18df14f..e48fe56 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -8,9 +8,11 @@ from salusfy import (Client, State, WebClient, HaTemperatureClient) +# pylint: disable=missing-function-docstring -@pytest.fixture -def mock_client(): + +@pytest.fixture(name="mock_client") +def mock_client_fixture(): state = State() state.current_temperature = 15.3 state.target_temperature = 33.3 @@ -21,8 +23,8 @@ def mock_client(): return mock -@pytest.fixture -def mock_ha_client(): +@pytest.fixture(name="mock_ha_client") +def mock_ha_client_fixture(): mock = Mock(HaTemperatureClient) mock.current_temperature.return_value = 21.1 @@ -110,7 +112,9 @@ async def test_client_sets_hvac_mode(mock_client, mock_ha_client): @pytest.mark.asyncio -async def test_client_assumes_hvac_action_as_heat_when_mode_is_heat_and_target_temp_is_high(mock_client, mock_ha_client): +async def test_client_assumes_hvac_action_as_heat_when_mode_is_heat_and_target_temp_is_high( + mock_client, mock_ha_client): + target = Client(mock_client, mock_ha_client) await target.get_state() @@ -124,7 +128,9 @@ async def test_client_assumes_hvac_action_as_heat_when_mode_is_heat_and_target_t @pytest.mark.asyncio -async def test_client_assumes_hvac_action_as_idle_when_mode_is_heat_and_target_temp_is_low(mock_client, mock_ha_client): +async def test_client_assumes_hvac_action_as_idle_when_mode_is_heat_and_target_temp_is_low( + mock_client, mock_ha_client): + target = Client(mock_client, mock_ha_client) await target.get_state() @@ -138,7 +144,9 @@ async def test_client_assumes_hvac_action_as_idle_when_mode_is_heat_and_target_t @pytest.mark.asyncio -async def test_client_assumes_hvac_action_as_heat_when_mode_is_heat_and_target_temp_is_set_high(mock_client, mock_ha_client): +async def test_client_assumes_hvac_action_as_heat_when_mode_is_heat_and_target_temp_is_set_high( + mock_client, mock_ha_client): + target = Client(mock_client, mock_ha_client) await target.get_state() @@ -152,7 +160,9 @@ async def test_client_assumes_hvac_action_as_heat_when_mode_is_heat_and_target_t @pytest.mark.asyncio -async def test_client_assumes_hvac_action_as_idle_when_mode_is_heat_and_target_temp_is_set_low(mock_client, mock_ha_client): +async def test_client_assumes_hvac_action_as_idle_when_mode_is_heat_and_target_temp_is_set_low( + mock_client, mock_ha_client): + target = Client(mock_client, mock_ha_client) await target.get_state() diff --git a/tests/test_climate.py b/tests/test_climate.py index 4b44d99..c21be84 100644 --- a/tests/test_climate.py +++ b/tests/test_climate.py @@ -6,13 +6,19 @@ from . import mock_config +# pylint: disable=missing-function-docstring + class MockHass: + """Mocks the HASS for use during unit tests.""" @property def services(self): return self - def has_service(self, domain, service): + def has_service(self, + domain, # pylint: disable=unused-argument + service, # pylint: disable=unused-argument + ): return False def async_register(self, domain, service, admin_handler, schema): @@ -23,7 +29,10 @@ def async_register(self, domain, service, admin_handler, schema): async def setup_climate_platform(): registry = EntityRegistry() config_adapter = ConfigAdapter(mock_config) - await climate.async_setup_platform(MockHass(), config_adapter, async_add_entities=registry.register, discovery_info=None) + await climate.async_setup_platform(MockHass(), + config_adapter, + async_add_entities=registry.register, + discovery_info=None) return registry diff --git a/tests/test_thermostat_entity.py b/tests/test_thermostat_entity.py index 5a61d0c..787dba5 100644 --- a/tests/test_thermostat_entity.py +++ b/tests/test_thermostat_entity.py @@ -4,12 +4,14 @@ from salusfy import (ThermostatEntity, State, WebClient) +# pylint: disable=missing-function-docstring -@pytest.fixture -def mock_client(): + +@pytest.fixture(name="mock_client") +def mock_client_fixture(): state = State() - state.current_temperature = 15.3 - state.target_temperature = 33.3 + state.current_temperature = 15.2 + state.target_temperature = 33.2 mock = Mock(WebClient) mock.get_state.return_value = state @@ -23,7 +25,7 @@ async def test_entity_returns_target_temp_from_web_client(mock_client): await target.async_update() - assert target.target_temperature == 33.3 + assert target.target_temperature == 33.2 @pytest.mark.asyncio diff --git a/tests/test_web_client.py b/tests/test_web_client.py index d3981da..a134331 100644 --- a/tests/test_web_client.py +++ b/tests/test_web_client.py @@ -7,9 +7,11 @@ from salusfy import WebClient +# pylint: disable=missing-function-docstring -@pytest.fixture -def payload() -> dict: + +@pytest.fixture(name="payload") +def payload_fixture() -> dict: """Returns the default data for the tests""" return { 'CH1currentSetPoint': 20.1,