diff --git a/,gitattributes b/,gitattributes
new file mode 100644
index 0000000..8f76d9e
--- /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..12315e0
--- /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..20cb428
--- /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/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..99da10e
--- /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@v4
+ - name: Set up Python 3.10
+ uses: actions/setup-python@v5
+ 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.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
+ 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
new file mode 100644
index 0000000..e37a8ef
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,4 @@
+config.py
+*.pyc
+__pycache__
+.pytest_cache/
\ No newline at end of file
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/.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/README.md b/README.md
index 19cb21f..f3d5397 100644
--- a/README.md
+++ b/README.md
@@ -1,43 +1,89 @@
-# 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 controll 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: "DEVICEID"
-```
-
-
-
-
-### Getting the DEVICEID
-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
-
-
-
-### 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...
+# Home-Assistant Custom Components
+
+[](https://github.com/matthewturner/salusfy/actions/workflows/ci.yml)
+
+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 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.
+
+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
+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
+
+```
+climate:
+ - platform: salusfy
+ username: "EMAIL"
+ password: "PASSWORD"
+ id: "DEVICE_ID"
+```
+
+
+
+
+### 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
+
+
+
+### 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:
+
+* 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/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
new file mode 100644
index 0000000..b2f819f
--- /dev/null
+++ b/config.sample.py
@@ -0,0 +1,14 @@
+# 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"
+
+SIMULATOR = False
+ENABLE_TEMPERATURE_CLIENT = False
+HOST = "your-home-assistant-ip-address"
+ENTITY_ID = "sensor.your-temperature-sensor"
+ACCESS_TOKEN = "your-HA-access-token"
diff --git a/custom_components/salusfy/__init__.py b/custom_components/salusfy/__init__.py
deleted file mode 100644
index cced37c..0000000
--- a/custom_components/salusfy/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-"""The Salus component."""
diff --git a/custom_components/salusfy/climate.py b/custom_components/salusfy/climate.py
deleted file mode 100644
index 80aa095..0000000
--- a/custom_components/salusfy/climate.py
+++ /dev/null
@@ -1,286 +0,0 @@
-"""
-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 (
- HVACAction,
- HVACMode,
- 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 homeassistant.helpers.reload import async_setup_reload_service
-
-__version__ = "0.0.3"
-
-
-_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 = ClimateEntityFeature.TARGET_TEMPERATURE
-
-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,
- }
-)
-
-
-# def setup_platform(hass, config, add_entities, discovery_info=None):
-async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
- 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)
- id = config.get(CONF_ID)
-
- # add_entities(
- # [SalusThermostat(name, username, password, id)]
- async_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
-
- @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()
-
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
diff --git a/install.sh b/install.sh
new file mode 100644
index 0000000..a13b1e4
--- /dev/null
+++ b/install.sh
@@ -0,0 +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.ci.txt b/requirements.ci.txt
new file mode 100644
index 0000000..2698041
--- /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
new file mode 100644
index 0000000..82ad30d
--- /dev/null
+++ b/requirements.test.txt
@@ -0,0 +1,4 @@
+pytest
+pytest-mock
+pytest-asyncio
+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
new file mode 100644
index 0000000..e69de29
diff --git a/run.py b/run.py
new file mode 100644
index 0000000..7c57fbe
--- /dev/null
+++ b/run.py
@@ -0,0 +1,59 @@
+"""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)
+# 3 Run with `python run.py`
+
+import asyncio
+import logging
+
+import argparse
+
+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
+
+
+import config
+
+logging.basicConfig(format='%(levelname)s: %(message)s', level=logging.DEBUG)
+
+
+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)
+
+ thermostat = registry.first
+
+ await thermostat.async_update()
+ await thermostat.async_update()
+
+ 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))
+ print("HVAC Action: " + thermostat.hvac_action)
+ print("HVAC Mode: " + thermostat.hvac_mode)
+
+
+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(args.set_temp))
diff --git a/salusfy/__init__.py b/salusfy/__init__.py
new file mode 100644
index 0000000..6ff4bcd
--- /dev/null
+++ b/salusfy/__init__.py
@@ -0,0 +1,7 @@
+"""The Salus component."""
+
+from .state import State
+from .web_client import WebClient
+from .thermostat_entity import ThermostatEntity
+from .ha_temperature_client import HaTemperatureClient
+from .client import Client
diff --git a/salusfy/client.py b/salusfy/client.py
new file mode 100644
index 0000000..47d115c
--- /dev/null
+++ b/salusfy/client.py
@@ -0,0 +1,83 @@
+"""
+Client which wraps the web client but handles
+the retrieval of current temperature by calling
+a specialized client.
+"""
+import logging
+
+from homeassistant.components.climate.const import (
+ HVACMode,
+ HVACAction,
+)
+
+from . import (
+ WebClient,
+ HaTemperatureClient,
+ State,
+)
+
+_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
+
+ 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()
+
+ 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)
+
+ self._state.mode = hvac_mode
+
+ 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...")
+ 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
diff --git a/salusfy/climate.py b/salusfy/climate.py
new file mode 100644
index 0000000..3fd3c6b
--- /dev/null
+++ b/salusfy/climate.py
@@ -0,0 +1,113 @@
+"""
+Adds support for the Salus Thermostat units.
+"""
+import logging
+from homeassistant.helpers.reload import async_setup_reload_service
+from homeassistant.components.climate import PLATFORM_SCHEMA
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+
+from homeassistant.const import (
+ CONF_PASSWORD,
+ CONF_USERNAME,
+ CONF_ID,
+ CONF_ENTITY_ID,
+ CONF_ACCESS_TOKEN,
+ CONF_HOST
+)
+
+from . import simulator
+from . import (
+ ThermostatEntity,
+ Client,
+ WebClient,
+ HaTemperatureClient,
+)
+
+CONF_SIMULATOR = 'simulator'
+CONF_ENABLE_TEMPERATURE_CLIENT = 'enable_temperature_client'
+
+
+__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.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):
+ """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)
+
+ name = config.get(CONF_NAME)
+ async_add_entities(
+ [ThermostatEntity(name, client)], update_before_add=True
+ )
+
+
+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)
+ enable_simulator = config.get(CONF_SIMULATOR)
+
+ if enable_simulator:
+ _LOGGER.info('Registering Salus Thermostat client simulator...')
+
+ return Client(simulator.WebClient(), simulator.TemperatureClient())
+
+ web_client = WebClient(username, password, device_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)
+ return Client(web_client, ha_client)
diff --git a/salusfy/ha_temperature_client.py b/salusfy/ha_temperature_client.py
new file mode 100644
index 0000000..f97c685
--- /dev/null
+++ b/salusfy/ha_temperature_client.py
@@ -0,0 +1,40 @@
+"""Reduces reliance on the Salus API"""
+import aiohttp
+
+# pylint: disable=too-few-public-methods
+
+
+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
+ 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)
diff --git a/custom_components/salusfy/manifest.json b/salusfy/manifest.json
similarity index 68%
rename from custom_components/salusfy/manifest.json
rename to salusfy/manifest.json
index 84b6d13..1eff3f4 100644
--- a/custom_components/salusfy/manifest.json
+++ b/salusfy/manifest.json
@@ -1,11 +1,13 @@
-{
- "domain": "salusfy",
- "name": "Salus thermostat",
- "version": "0.0.1",
- "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.3.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/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/simulator/__init__.py b/salusfy/simulator/__init__.py
new file mode 100644
index 0000000..954253c
--- /dev/null
+++ b/salusfy/simulator/__init__.py
@@ -0,0 +1,2 @@
+from .temperature_client import TemperatureClient
+from .web_client import WebClient
diff --git a/salusfy/simulator/temperature_client.py b/salusfy/simulator/temperature_client.py
new file mode 100644
index 0000000..2ae6024
--- /dev/null
+++ b/salusfy/simulator/temperature_client.py
@@ -0,0 +1,11 @@
+"""
+Adds support for simulating the Salus Thermostats.
+"""
+
+
+class TemperatureClient:
+ def __init__(self):
+ pass
+
+ async def current_temperature(self) -> float:
+ return 15.9
diff --git a/salusfy/simulator/web_client.py b/salusfy/simulator/web_client.py
new file mode 100644
index 0000000..ea99558
--- /dev/null
+++ b/salusfy/simulator/web_client.py
@@ -0,0 +1,40 @@
+"""
+Adds support for simulating the Salus Thermostats.
+"""
+import logging
+
+from homeassistant.components.climate.const import HVACMode
+
+from ..state import State
+
+
+_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
+
+ async def set_temperature(self, temperature: float) -> None:
+ """Set new target temperature."""
+
+ _LOGGER.info("Setting temperature to %.1f...", 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
new file mode 100644
index 0000000..f2924de
--- /dev/null
+++ b/salusfy/state.py
@@ -0,0 +1,13 @@
+"""Exposes state of the thermostat."""
+
+import dataclasses
+
+
+@dataclasses.dataclass
+class State:
+ """The state of the thermostat."""
+ current_temperature = None
+ target_temperature = None
+ frost = None
+ action = None
+ mode = None
diff --git a/salusfy/thermostat_entity.py b/salusfy/thermostat_entity.py
new file mode 100644
index 0000000..b2cdac9
--- /dev/null
+++ b/salusfy/thermostat_entity.py
@@ -0,0 +1,136 @@
+from homeassistant.components.climate.const import (
+ HVACAction,
+ HVACMode,
+ ClimateEntityFeature,
+ PRESET_NONE,
+)
+
+from homeassistant.const import (
+ ATTR_TEMPERATURE,
+ UnitOfTemperature,
+)
+
+from .web_client import (
+ MAX_TEMP,
+ MIN_TEMP
+)
+
+try:
+ from homeassistant.components.climate import ClimateEntity
+except ImportError:
+ from homeassistant.components.climate import ClimateDevice as ClimateEntity
+
+
+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._enable_turn_on_off_backwards_compatibility = False
+
+ @property
+ 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) -> str:
+ """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) -> bool:
+ """Return if polling is required."""
+ return True
+
+ @property
+ def min_temp(self) -> float:
+ """Return the minimum temperature."""
+ return MIN_TEMP
+
+ @property
+ def max_temp(self) -> float:
+ """Return the maximum temperature."""
+ return MAX_TEMP
+
+ @property
+ def temperature_unit(self) -> UnitOfTemperature:
+ """Return the unit of measurement."""
+ return UnitOfTemperature.CELSIUS
+
+ @property
+ def current_temperature(self) -> float:
+ """Return the current temperature."""
+ return self._state.current_temperature
+
+ @property
+ 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."""
+
+ 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)
+
+ 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
new file mode 100644
index 0000000..fbc7270
--- /dev/null
+++ b/salusfy/web_client.py
@@ -0,0 +1,192 @@
+"""
+Adds support for the Salus Thermostat units.
+"""
+import time
+import logging
+import re
+import json
+import aiohttp
+
+from homeassistant.components.climate.const import (
+ HVACMode,
+ HVACAction,
+)
+
+from .state import State
+
+_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: str, password: str, device_id: str):
+ """Initialize the client."""
+ self._username = username
+ self._password = password
+ self._id = device_id
+ self._token = None
+ self._token_retrieved_at = None
+
+ async def set_temperature(self, temperature: float) -> None:
+ """Set new target temperature, via URL commands."""
+
+ _LOGGER.info("Setting the temperature to %.1f...", temperature)
+
+ 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 session.post(URL_SET_DATA, data=payload, headers=headers)
+ _LOGGER.info("Salusfy set_temperature: OK")
+ except BaseException:
+ _LOGGER.error("Error Setting the 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)
+
+ headers = {"Content-Type": "application/x-www-form-urlencoded"}
+
+ auto = "1"
+ if hvac_mode == HVACMode.OFF:
+ 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"}
+ try:
+ await session.post(URL_SET_DATA, data=payload, headers=headers)
+ except BaseException:
+ _LOGGER.error("Error Setting HVAC mode to %s", hvac_mode)
+
+ 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:
+ _LOGGER.info("Retrieving token for the first time this session...")
+ await self.get_token(session)
+ return self._token
+
+ if self._token_retrieved_at > 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:
+ """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:
+ await session.post(URL_LOGIN, data=payload, headers=headers)
+ params = {"devId": self._id}
+ 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._token_retrieved_at = time.time()
+ except Exception as e:
+ self._token = None
+ self._token_retrieved_at = 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"""
+
+ _LOGGER.info("Retrieving current state from Salus Gateway...")
+
+ 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"])
+ state.frost = float(data["frost"])
+
+ status = data['CH1heatOnOffStatus']
+ if status == "1":
+ state.action = HVACAction.HEATING
+ else:
+ state.action = HVACAction.IDLE
+
+ heat_on_off = data['CH1heatOnOff']
+ auto_mode = data['CH1autoMode']
+ if heat_on_off == "0" and auto_mode == "0":
+ state.mode = HVACMode.AUTO
+ else:
+ if heat_on_off == "1":
+ state.mode = HVACMode.OFF
+ else:
+ state.mode = HVACMode.HEAT
+
+ return state
+
+ async def get_state_data(self) -> dict:
+ """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 BaseException:
+ _LOGGER.error(
+ "Error Getting the data from Salus. Check the connection to salus-it500.com.")
+ return None
+
+ body = await r.text()
+ _LOGGER.info("Salusfy get_data output %s", body)
+ data = json.loads(body)
+
+ return data
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..5c30d22
--- /dev/null
+++ b/tests/config_adapter.py
@@ -0,0 +1,44 @@
+# pylint: disable=too-few-public-methods
+# pylint: disable=too-many-return-statements
+
+class ConfigAdapter:
+ """Simulates how Home Assistant loads configuration"""
+
+ def __init__(self, config):
+ self._config = config
+
+ def get(self, key: str) -> any:
+ """Returns the config value based on the Home Assistant 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':
+ 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
+
+ if key == 'entity_id':
+ return self._config.ENTITY_ID
+
+ if key == 'access_token':
+ return self._config.ACCESS_TOKEN
+
+ return 'Unknown'
diff --git a/tests/entity_registry.py b/tests/entity_registry.py
new file mode 100644
index 0000000..33539ca
--- /dev/null
+++ b/tests/entity_registry.py
@@ -0,0 +1,29 @@
+class EntityRegistry:
+ """Registry used for local and test executions."""
+
+ def __init__(self):
+ self._entities = []
+ self._update_before_add = False
+
+ def register(self, entities, **kwargs):
+ """Registers the list of entities."""
+ self._update_before_add = kwargs.get('update_before_add')
+ 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/mock_config.py b/tests/mock_config.py
new file mode 100644
index 0000000..3c2a44d
--- /dev/null
+++ b/tests/mock_config.py
@@ -0,0 +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
+HOST = "192.168.0.99"
diff --git a/tests/test_client.py b/tests/test_client.py
new file mode 100644
index 0000000..e48fe56
--- /dev/null
+++ b/tests/test_client.py
@@ -0,0 +1,175 @@
+from unittest.mock import Mock
+import pytest
+
+from homeassistant.components.climate.const import (
+ HVACMode,
+ HVACAction
+)
+
+from salusfy import (Client, State, WebClient, HaTemperatureClient)
+
+# pylint: disable=missing-function-docstring
+
+
+@pytest.fixture(name="mock_client")
+def mock_client_fixture():
+ state = State()
+ state.current_temperature = 15.3
+ state.target_temperature = 33.3
+
+ mock = Mock(WebClient)
+ mock.get_state.return_value = state
+
+ return mock
+
+
+@pytest.fixture(name="mock_ha_client")
+def mock_ha_client_fixture():
+ mock = Mock(HaTemperatureClient)
+
+ mock.current_temperature.return_value = 21.1
+
+ return mock
+
+
+@pytest.mark.asyncio
+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()
+
+ assert actual.target_temperature == 33.3
+
+
+@pytest.mark.asyncio
+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()
+
+ assert actual.current_temperature == 21.1
+
+
+@pytest.mark.asyncio
+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()
+ await target.get_state()
+
+ mock_client.get_state.assert_called_once()
+
+ actual = await target.get_state()
+ assert actual.target_temperature == 33.3
+
+
+@pytest.mark.asyncio
+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_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)
+
+ 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
diff --git a/tests/test_climate.py b/tests/test_climate.py
new file mode 100644
index 0000000..c21be84
--- /dev/null
+++ b/tests/test_climate.py
@@ -0,0 +1,72 @@
+import pytest
+
+from salusfy import climate
+from .config_adapter import ConfigAdapter
+from .entity_registry import EntityRegistry
+
+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, # pylint: disable=unused-argument
+ service, # pylint: disable=unused-argument
+ ):
+ 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_is_updated_before_added():
+ registry = await setup_climate_platform()
+
+ assert registry.update_before_add
+
+
+@pytest.mark.asyncio
+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
+
+
+@pytest.mark.asyncio
+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
diff --git a/tests/test_thermostat_entity.py b/tests/test_thermostat_entity.py
new file mode 100644
index 0000000..787dba5
--- /dev/null
+++ b/tests/test_thermostat_entity.py
@@ -0,0 +1,52 @@
+from unittest.mock import Mock
+from homeassistant.components.climate.const import HVACMode
+import pytest
+
+from salusfy import (ThermostatEntity, State, WebClient)
+
+# pylint: disable=missing-function-docstring
+
+
+@pytest.fixture(name="mock_client")
+def mock_client_fixture():
+ state = State()
+ state.current_temperature = 15.2
+ state.target_temperature = 33.2
+
+ mock = Mock(WebClient)
+ mock.get_state.return_value = state
+
+ return mock
+
+
+@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.2
+
+
+@pytest.mark.asyncio
+async def test_entity_delegates_set_temperature_web_client(mock_client):
+ target = ThermostatEntity('mock', mock_client)
+
+ await target.async_update()
+
+ await target.async_set_temperature(temperature=29.9)
+
+ mock_client.set_temperature.assert_called_once_with(29.9)
+ assert target.target_temperature == 29.9
+
+
+@pytest.mark.asyncio
+async def test_entity_delegates_set_hvac_mode_to_web_client(mock_client):
+ target = ThermostatEntity('mock', mock_client)
+
+ await target.async_update()
+
+ 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
diff --git a/tests/test_web_client.py b/tests/test_web_client.py
new file mode 100644
index 0000000..a134331
--- /dev/null
+++ b/tests/test_web_client.py
@@ -0,0 +1,77 @@
+import pytest
+
+from homeassistant.components.climate.const import (
+ HVACMode,
+ HVACAction
+)
+
+from salusfy import WebClient
+
+# pylint: disable=missing-function-docstring
+
+
+@pytest.fixture(name="payload")
+def payload_fixture() -> 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