├── __init__.py ├── tests ├── __init__.py └── test_encryptor.py ├── setup.cfg ├── pyit600 ├── __version__.py ├── __init__.py ├── exceptions.py ├── gateway_singleton.py ├── const.py ├── encryptor.py ├── models.py └── gateway.py ├── LICENSE ├── setup.py ├── .gitignore ├── README.md └── main.py /__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Tests for pyit600.""" 2 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description-file = README.md -------------------------------------------------------------------------------- /pyit600/__version__.py: -------------------------------------------------------------------------------- 1 | """Asynchronous Python client for Salus iT600 smart devices.""" 2 | 3 | __version__ = "0.5.1" 4 | -------------------------------------------------------------------------------- /pyit600/__init__.py: -------------------------------------------------------------------------------- 1 | """Asynchronous Python client for Salus iT600 smart devices.""" 2 | 3 | from .exceptions import ( 4 | IT600AuthenticationError, 5 | IT600CommandError, 6 | IT600ConnectionError, 7 | ) 8 | from .gateway import IT600Gateway 9 | -------------------------------------------------------------------------------- /pyit600/exceptions.py: -------------------------------------------------------------------------------- 1 | """Exceptions for Salus iT600 smart devices.""" 2 | 3 | 4 | class IT600Error(Exception): 5 | """Salus iT600 exception.""" 6 | 7 | pass 8 | 9 | 10 | class IT600AuthenticationError(IT600Error): 11 | """Salus iT600 authentication exception.""" 12 | 13 | pass 14 | 15 | 16 | class IT600CommandError(IT600Error): 17 | """Salus iT600 command exception.""" 18 | 19 | pass 20 | 21 | 22 | class IT600ConnectionError(IT600Error): 23 | """Salus iT600 connection exception.""" 24 | 25 | pass 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Julius Vitkauskas 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /pyit600/gateway_singleton.py: -------------------------------------------------------------------------------- 1 | """Salus iT600 gateway API singleton.""" 2 | 3 | import aiohttp 4 | import threading 5 | 6 | from pyit600 import IT600Gateway 7 | 8 | 9 | class IT600GatewaySingleton: 10 | __lock__ = threading.Lock() 11 | __instance__: IT600Gateway = None 12 | 13 | @staticmethod 14 | def get_instance( 15 | euid: str, 16 | host: str, 17 | port: int = 80, 18 | request_timeout: int = 5, 19 | session: aiohttp.client.ClientSession = None, 20 | debug: bool = False, 21 | ) -> IT600Gateway: 22 | if not IT600GatewaySingleton.__instance__: 23 | with IT600GatewaySingleton.__lock__: 24 | if not IT600GatewaySingleton.__instance__: 25 | IT600GatewaySingleton.__instance__ = IT600Gateway( 26 | euid=euid, 27 | host=host, 28 | port=port, 29 | request_timeout=request_timeout, 30 | session=session, 31 | debug=debug 32 | ) 33 | return IT600GatewaySingleton.__instance__ 34 | -------------------------------------------------------------------------------- /pyit600/const.py: -------------------------------------------------------------------------------- 1 | """Constants for the Salus iT600 smart devices.""" 2 | 3 | # Degree units 4 | DEGREE = "°" 5 | 6 | # Temperature units 7 | TEMP_CELSIUS = f"{DEGREE}C" 8 | 9 | # States 10 | STATE_UNKNOWN = "unknown" 11 | 12 | # Supported climate features 13 | SUPPORT_TARGET_TEMPERATURE = 1 14 | SUPPORT_FAN_MODE = 8 15 | SUPPORT_PRESET_MODE = 16 16 | 17 | # Supported cover features 18 | SUPPORT_OPEN = 1 19 | SUPPORT_CLOSE = 2 20 | SUPPORT_SET_POSITION = 4 21 | 22 | # HVAC modes 23 | HVAC_MODE_OFF = "off" 24 | HVAC_MODE_HEAT = "heat" 25 | HVAC_MODE_COOL = "cool" 26 | HVAC_MODE_AUTO = "auto" 27 | 28 | # HVAC states 29 | CURRENT_HVAC_OFF = "off" 30 | CURRENT_HVAC_HEAT = "heating" 31 | CURRENT_HVAC_HEAT_IDLE = "heating (idling)" 32 | CURRENT_HVAC_COOL = "cooling" 33 | CURRENT_HVAC_COOL_IDLE = "cooling (idling)" 34 | CURRENT_HVAC_IDLE = "idle" 35 | 36 | # Supported presets 37 | PRESET_FOLLOW_SCHEDULE = "Follow Schedule" 38 | PRESET_PERMANENT_HOLD = "Permanent Hold" 39 | PRESET_TEMPORARY_HOLD = "Temporary Hold" 40 | PRESET_ECO = "Eco" 41 | PRESET_OFF = "Off" 42 | 43 | # Supported fan modes 44 | FAN_MODE_AUTO = "Auto" 45 | FAN_MODE_HIGH = "High" 46 | FAN_MODE_MEDIUM = "Medium" 47 | FAN_MODE_LOW = "Low" 48 | FAN_MODE_OFF = "Off" 49 | -------------------------------------------------------------------------------- /pyit600/encryptor.py: -------------------------------------------------------------------------------- 1 | """Encryptor for Salus iT600 local mode communication.""" 2 | 3 | import hashlib 4 | 5 | from cryptography.hazmat.backends import default_backend 6 | from cryptography.hazmat.primitives import padding 7 | from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes 8 | 9 | 10 | class IT600Encryptor: 11 | iv = bytes([0x88, 0xa6, 0xb0, 0x79, 0x5d, 0x85, 0xdb, 0xfc, 0xe6, 0xe0, 0xb3, 0xe9, 0xa6, 0x29, 0x65, 0x4b]) 12 | 13 | def __init__(self, euid: str): 14 | key: bytes = hashlib.md5(f"Salus-{euid.lower()}".encode("utf-8")).digest() + bytes([0] * 16) 15 | self.cipher = Cipher(algorithms.AES(key), modes.CBC(self.iv), default_backend()) 16 | 17 | def encrypt(self, plain: str) -> bytes: 18 | encryptor = self.cipher.encryptor() 19 | padder = padding.PKCS7(128).padder() 20 | padded_data: bytes = padder.update(plain.encode("utf-8")) + padder.finalize() 21 | return encryptor.update(padded_data) + encryptor.finalize() 22 | 23 | def decrypt(self, cypher: bytes) -> str: 24 | decryptor = self.cipher.decryptor() 25 | padded_data: bytes = decryptor.update(cypher) + decryptor.finalize() 26 | unpadder = padding.PKCS7(128).unpadder() 27 | plain: bytes = unpadder.update(padded_data) + unpadder.finalize() 28 | return plain.decode("utf-8") 29 | -------------------------------------------------------------------------------- /tests/test_encryptor.py: -------------------------------------------------------------------------------- 1 | """Tests for Salus iT600 local mode encryption.""" 2 | 3 | import unittest 4 | 5 | from pyit600 import encryptor 6 | 7 | 8 | class TestStringMethods(unittest.TestCase): 9 | test_euid = '0123456789abcdef' 10 | test_plaintext = 'plain' 11 | test_ciphertext = b'\x1a\x84\x9d\xeffE\xbd\xa5d\x16+!\x9b2\x94\x85' 12 | 13 | def test_encryption(self): 14 | cryptor = encryptor.IT600Encryptor(self.test_euid) 15 | ciphertext = cryptor.encrypt(self.test_plaintext) 16 | 17 | self.assertEqual(self.test_ciphertext, ciphertext) 18 | 19 | def test_decryption(self): 20 | cryptor = encryptor.IT600Encryptor(self.test_euid) 21 | plaintext = cryptor.decrypt(self.test_ciphertext) 22 | 23 | self.assertEqual(self.test_plaintext, plaintext) 24 | 25 | def test_encryption_decryption_cycle(self): 26 | cryptor = encryptor.IT600Encryptor(self.test_euid) 27 | ciphertext = cryptor.encrypt(self.test_plaintext) 28 | plaintext = cryptor.decrypt(ciphertext) 29 | 30 | self.assertEqual(self.test_plaintext, plaintext) 31 | 32 | def test_key_case_insensitivity(self): 33 | ciphertext1 = encryptor.IT600Encryptor(self.test_euid.lower()).encrypt(self.test_plaintext) 34 | ciphertext2 = encryptor.IT600Encryptor(self.test_euid.upper()).encrypt(self.test_plaintext) 35 | 36 | self.assertEqual(ciphertext1, ciphertext2) 37 | 38 | 39 | if __name__ == '__main__': 40 | unittest.main() 41 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """The setup script.""" 3 | import os 4 | import re 5 | import sys 6 | 7 | from setuptools import find_packages, setup 8 | 9 | 10 | def get_version(): 11 | """Get current version from code.""" 12 | regex = r"__version__\s=\s\"(?P[\d\.]+?)\"" 13 | path = ("pyit600", "__version__.py") 14 | return re.search(regex, read(*path)).group("version") 15 | 16 | 17 | def read(*parts): 18 | """Read file.""" 19 | filename = os.path.join(os.path.abspath(os.path.dirname(__file__)), *parts) 20 | sys.stdout.write(filename) 21 | with open(filename, encoding="utf-8", mode="rt") as fp: 22 | return fp.read() 23 | 24 | 25 | with open("README.md") as readme_file: 26 | readme = readme_file.read() 27 | 28 | setup(author="Julius Vitkauskas", 29 | author_email="zadintuvas@gmail.com", 30 | classifiers=[ 31 | "Development Status :: 3 - Alpha", 32 | "Framework :: AsyncIO", 33 | "Programming Language :: Python :: 3.7", 34 | "Topic :: Scientific/Engineering :: Interface Engine/Protocol Translator" 35 | ], 36 | description="Asynchronous Python client for Salus IT600 devices", 37 | include_package_data=True, 38 | install_requires=["aiohttp>=3.8.1", "cryptography>=38.0.1"], 39 | keywords=["salus", "it600", "api", "async", "client"], 40 | license="MIT license", 41 | long_description_content_type="text/markdown", 42 | long_description=readme, 43 | name="pyit600", 44 | packages=find_packages(include=["pyit600"]), 45 | test_suite="tests", 46 | url="https://github.com/jvitkauskas/pyit600", 47 | version=get_version(), 48 | zip_safe=False, 49 | python_requires='>=3.7', 50 | ) 51 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # OSX useful to ignore 7 | *.DS_Store 8 | .AppleDouble 9 | .LSOverride 10 | 11 | # Thumbnails 12 | ._* 13 | 14 | # Files that might appear in the root of a volume 15 | .DocumentRevisions-V100 16 | .fseventsd 17 | .Spotlight-V100 18 | .TemporaryItems 19 | .Trashes 20 | .VolumeIcon.icns 21 | .com.apple.timemachine.donotpresent 22 | 23 | # Directories potentially created on remote AFP share 24 | .AppleDB 25 | .AppleDesktop 26 | Network Trash Folder 27 | Temporary Items 28 | .apdisk 29 | 30 | # C extensions 31 | *.so 32 | 33 | # Distribution / packaging 34 | .Python 35 | env/ 36 | build/ 37 | develop-eggs/ 38 | dist/ 39 | downloads/ 40 | eggs/ 41 | .eggs/ 42 | lib/ 43 | lib64/ 44 | parts/ 45 | sdist/ 46 | var/ 47 | *.egg-info/ 48 | .installed.cfg 49 | *.egg 50 | 51 | # PyInstaller 52 | # Usually these files are written by a python script from a template 53 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 54 | *.manifest 55 | *.spec 56 | 57 | # Installer logs 58 | pip-log.txt 59 | pip-delete-this-directory.txt 60 | 61 | # Unit test / coverage reports 62 | htmlcov/ 63 | .tox/ 64 | .coverage 65 | .coverage.* 66 | .cache 67 | nosetests.xml 68 | coverage.xml 69 | *,cover 70 | .hypothesis/ 71 | .pytest_cache/ 72 | 73 | # Translations 74 | *.mo 75 | *.pot 76 | 77 | # Django stuff: 78 | *.log 79 | 80 | # Sphinx documentation 81 | docs/_build/ 82 | 83 | # pyenv 84 | .python-version 85 | 86 | # virtualenv 87 | .venv 88 | venv/ 89 | ENV/ 90 | 91 | # mypy 92 | .mypy_cache/ 93 | 94 | # Visual Studio Code 95 | .vscode 96 | 97 | # IntelliJ Idea family of suites 98 | .idea 99 | *.iml 100 | 101 | ## File-based project format: 102 | *.ipr 103 | *.iws 104 | 105 | ## mpeltonen/sbt-idea plugin 106 | .idea_modules/ 107 | 108 | # PyBuilder 109 | target/ 110 | 111 | # Cookiecutter 112 | output/ 113 | python_boilerplate/ 114 | -------------------------------------------------------------------------------- /pyit600/models.py: -------------------------------------------------------------------------------- 1 | """Salus iT600 smart device models.""" 2 | 3 | from typing import List, NamedTuple, Optional, Any 4 | 5 | 6 | class GatewayDevice(NamedTuple): 7 | name: str 8 | unique_id: str 9 | data: dict 10 | manufacturer: str 11 | model: Optional[str] 12 | sw_version: Optional[str] 13 | 14 | 15 | class ClimateDevice(NamedTuple): 16 | available: bool 17 | name: str 18 | unique_id: str 19 | temperature_unit: str 20 | precision: float 21 | current_temperature: float 22 | target_temperature: float 23 | max_temp: float 24 | min_temp: float 25 | current_humidity: Optional[float] 26 | hvac_mode: str 27 | hvac_action: str 28 | hvac_modes: List[str] 29 | preset_mode: str 30 | preset_modes: List[str] 31 | fan_mode: Optional[str] 32 | fan_modes: Optional[List[str]] 33 | locked: Optional[bool] 34 | supported_features: int 35 | device_class: str 36 | data: dict 37 | manufacturer: str 38 | model: Optional[str] 39 | sw_version: Optional[str] 40 | 41 | 42 | class BinarySensorDevice(NamedTuple): 43 | available: bool 44 | name: str 45 | unique_id: str 46 | is_on: bool 47 | device_class: str 48 | data: dict 49 | manufacturer: str 50 | model: Optional[str] 51 | sw_version: Optional[str] 52 | 53 | 54 | class SwitchDevice(NamedTuple): 55 | available: bool 56 | name: str 57 | unique_id: str 58 | is_on: bool 59 | device_class: str 60 | data: dict 61 | manufacturer: str 62 | model: Optional[str] 63 | sw_version: Optional[str] 64 | 65 | 66 | class CoverDevice(NamedTuple): 67 | available: bool 68 | name: str 69 | unique_id: str 70 | current_cover_position: Optional[int] 71 | is_opening: Optional[bool] 72 | is_closing: Optional[bool] 73 | is_closed: bool 74 | supported_features: int 75 | device_class: Optional[str] 76 | data: dict 77 | manufacturer: str 78 | model: Optional[str] 79 | sw_version: Optional[str] 80 | 81 | 82 | class SensorDevice(NamedTuple): 83 | available: bool 84 | name: str 85 | unique_id: str 86 | state: Any 87 | unit_of_measurement: str 88 | device_class: str 89 | data: dict 90 | manufacturer: str 91 | model: Optional[str] 92 | sw_version: Optional[str] 93 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Python: Asynchronous client for Salus iT600 devices 2 | 3 | ## For end users 4 | See https://github.com/epoplavskis/homeassistant_salus to use this in Home Assistant. 5 | 6 | FHEM users might be interested in https://github.com/dominikkarall/fhempy which provides subset of functionality. 7 | 8 | ## About 9 | 10 | This package allows you to control and monitor your Salus iT600 smart home devices locally through Salus UG600 universal gateway. Currently heating thermostats, binary sensors, temperature sensors, covers and switches are supported. You have any other devices and would like to contribute - you are welcome to create an issue or submit a pull request. 11 | 12 | ## Installation 13 | 14 | ```bash 15 | pip install pyit600 16 | ``` 17 | 18 | ## Usage 19 | - Instantiate the IT600Gateway device with local ip address and EUID of your gateway. You can find EUID written down on the bottom of your gateway (eg. `001E5E0D32906128`). 20 | - Status can be polled using the `poll_status()` command. 21 | - Callbacks to be notified of state updates can be added with the `add_climate_update_callback(method)` or `add_sensor_update_callback(method)` method. 22 | 23 | ### Basic example 24 | 25 | ```python 26 | async with IT600Gateway(host=args.host, euid=args.euid) as gateway: 27 | await gateway.connect() 28 | await gateway.poll_status() 29 | 30 | climate_devices = gateway.get_climate_devices() 31 | 32 | print("All climate devices:") 33 | print(repr(climate_devices)) 34 | 35 | for climate_device_id in climate_devices: 36 | print(f"Climate device {climate_device_id} status:") 37 | print(repr(climate_devices.get(climate_device_id))) 38 | 39 | print(f"Setting heating device {climate_device_id} temperature to 21 degrees celsius") 40 | await gateway.set_climate_device_temperature(climate_device_id, 21) 41 | ``` 42 | 43 | ### Supported devices 44 | 45 | Thermostats: 46 | * HTRP-RF(50) 47 | * TS600 48 | * VS10WRF/VS10BRF 49 | * VS20WRF/VS20BRF 50 | * SQ610 51 | * SQ610RF 52 | * FC600 53 | 54 | Binary sensors: 55 | * SW600 56 | * WLS600 57 | * OS600 58 | * SD600 (sometimes gateway may not expose required information for these devices to be detected, reason is unknown) 59 | * TRV10RFM (only heating state on/off) 60 | * RX10RF (only heating state on/off) 61 | 62 | Temperature sensors: 63 | * PS600 64 | 65 | Switch devices: 66 | * SPE600 67 | * RS600 68 | * SR600 69 | 70 | Cover devices: 71 | * RS600 72 | 73 | ### Unsupported devices 74 | 75 | Buttons perform actions only in Salus Smart Home app: 76 | * SB600 77 | * CSB600 78 | 79 | ### Untested devices 80 | 81 | These switch devices have not been tested, but may work: 82 | * SP600 83 | 84 | These binary sensors have not been tested, but may work: 85 | * MS600 86 | 87 | ### Troubleshooting 88 | 89 | If you can't connect using EUID written down on the bottom of your gateway (which looks something like `001E5E0D32906128`), try using `0000000000000000` as EUID. 90 | 91 | Also check if you have "Local Wifi Mode" enabled: 92 | * Open Smart Home app on your phone 93 | * Sign in 94 | * Double tap your Gateway to open info screen 95 | * Press gear icon to enter configuration 96 | * Scroll down a bit and check if "Disable Local WiFi Mode" is set to "No" 97 | * Scroll all the way down and save settings 98 | * Restart Gateway by unplugging/plugging USB power 99 | 100 | 101 | ### Contributing 102 | 103 | If you want to help to get your device supported, open GitHub issue and add your device model number and output of `main.py` program. Be sure to run this program with --debug option. 104 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import argparse 3 | import asyncio 4 | import logging 5 | import sys 6 | 7 | from pyit600.exceptions import IT600AuthenticationError, IT600ConnectionError 8 | from pyit600.gateway_singleton import IT600GatewaySingleton 9 | 10 | 11 | def help(): 12 | print("pyit600 demo app") 13 | print("syntax: main.py [options]") 14 | print("options:") 15 | print(" --host ... network address of your Salus UG600 universal gateway") 16 | print(" --euid ... EUID which is specified on the bottom of your gateway") 17 | print(" --debug ... use this if you want to print requests/responses") 18 | print() 19 | print("examples:") 20 | print(" main.py --host 192.168.0.125 --euid 001E5E0D32906128 --debug") 21 | 22 | 23 | async def my_climate_callback(device_id): 24 | print("Got callback for climate device id: " + device_id) 25 | 26 | 27 | async def my_sensor_callback(device_id): 28 | print("Got callback for sensor device id: " + device_id) 29 | 30 | 31 | async def my_switch_callback(device_id): 32 | print("Got callback for switch device id: " + device_id) 33 | 34 | 35 | async def my_cover_callback(device_id): 36 | print("Got callback for cover device id: " + device_id) 37 | 38 | 39 | async def main(): 40 | logging.basicConfig(level=logging.DEBUG) 41 | parser = argparse.ArgumentParser(description="Commands: mode fan temp") 42 | parser.add_argument( 43 | "--host", 44 | type=str, 45 | dest="host", 46 | help="network address of your Salus UG600 universal gateway", 47 | metavar="HOST", 48 | default=None, 49 | ) 50 | parser.add_argument( 51 | "--euid", 52 | type=str, 53 | dest="euid", 54 | help="EUID which is specified on the bottom of your gateway", 55 | metavar="EUID", 56 | default=None, 57 | ) 58 | parser.add_argument( 59 | "--debug", 60 | dest="debug", 61 | help="Debug mode which prints requests/responses", 62 | action="store_true", 63 | ) 64 | args = parser.parse_args() 65 | 66 | if (not args.host) or (not args.euid): 67 | help() 68 | sys.exit(0) 69 | 70 | async with IT600GatewaySingleton.get_instance(host=args.host, euid=args.euid, debug=args.debug) as gateway: 71 | try: 72 | await gateway.connect() 73 | except IT600ConnectionError: 74 | print("Connection error: check if you have specified gateway's IP address correctly.", file=sys.stderr) 75 | sys.exit(1) 76 | except IT600AuthenticationError: 77 | print("Authentication error: check if you have specified gateway's EUID correctly.", file=sys.stderr) 78 | sys.exit(2) 79 | 80 | await gateway.add_climate_update_callback(my_climate_callback) 81 | await gateway.add_binary_sensor_update_callback(my_sensor_callback) 82 | await gateway.add_switch_update_callback(my_switch_callback) 83 | await gateway.add_cover_update_callback(my_cover_callback) 84 | 85 | await gateway.poll_status(send_callback=True) 86 | 87 | climate_devices = gateway.get_climate_devices() 88 | 89 | if not climate_devices: 90 | print( 91 | """Warning: no climate devices found. Ensure that you have paired your thermostat(s) with gateway and you can see it in the official Salus app. If it works there, your thermostat might not be supported. If you want to help to get it supported, open GitHub issue and add your thermostat model number and output of this program. Be sure to run this program with --debug option.\n""") 92 | else: 93 | print("All climate devices:") 94 | print(repr(climate_devices)) 95 | 96 | for climate_device_id in climate_devices: 97 | print(f"Climate device {climate_device_id} status:") 98 | print(repr(climate_devices.get(climate_device_id))) 99 | 100 | print(f"Setting heating device {climate_device_id} temperature to 21 degrees celsius") 101 | await gateway.set_climate_device_temperature(climate_device_id, 21) 102 | 103 | binary_sensor_devices = gateway.get_binary_sensor_devices() 104 | 105 | if not binary_sensor_devices: 106 | print( 107 | """Warning: no binary sensor devices found. Ensure that you have paired your binary sensor(s) with gateway and you can see it in the official Salus app. If it works there, your sensor might not be supported. If you want to help to get it supported, open GitHub issue and add your binary sensor model number and output of this program. Be sure to run this program with --debug option.\n""") 108 | else: 109 | print("All binary sensor devices:") 110 | print(repr(binary_sensor_devices)) 111 | 112 | for binary_sensor_device_id in binary_sensor_devices: 113 | print(f"Binary sensor device {binary_sensor_device_id} status:") 114 | device = binary_sensor_devices.get(binary_sensor_device_id) 115 | print(repr(device)) 116 | 117 | print(f"'{device.name}' is on: {device.is_on}") 118 | 119 | switch_devices = gateway.get_switch_devices() 120 | 121 | if not switch_devices: 122 | print( 123 | """Warning: no switch devices found. Ensure that you have paired your switch(es) with gateway and you can see it in the official Salus app. If it works there, your switch might not be supported. If you want to help to get it supported, open GitHub issue and add your switch model number and output of this program. Be sure to run this program with --debug option.\n""") 124 | else: 125 | print("All switch devices:") 126 | print(repr(switch_devices)) 127 | 128 | for switch_device_id in switch_devices: 129 | print(f"Switch device {switch_device_id} status:") 130 | device = switch_devices.get(switch_device_id) 131 | print(repr(device)) 132 | 133 | print(f"Toggling device {switch_device_id}") 134 | if device.is_on: 135 | await gateway.turn_off_switch_device(switch_device_id) 136 | else: 137 | await gateway.turn_on_switch_device(switch_device_id) 138 | 139 | cover_devices = gateway.get_cover_devices() 140 | 141 | if not cover_devices: 142 | print( 143 | """Warning: no cover devices found. Ensure that you have paired your cover(s) with gateway and you can see it in the official Salus app. If it works there, your cover might not be supported. If you want to help to get it supported, open GitHub issue and add your cover model number and output of this program. Be sure to run this program with --debug option.\n""") 144 | else: 145 | print("All cover devices:") 146 | print(repr(cover_devices)) 147 | 148 | for sensor_device_id in cover_devices: 149 | print(f"Switch device {sensor_device_id} status:") 150 | device = switch_devices.get(sensor_device_id) 151 | print(repr(device)) 152 | 153 | print(f"Setting {sensor_device_id} to 25%") 154 | await gateway.set_cover_position(sensor_device_id, 25) 155 | 156 | sensor_devices = gateway.get_sensor_devices() 157 | 158 | if not sensor_devices: 159 | print( 160 | """Warning: no sensor devices found. Ensure that you have paired your sensor(s) with gateway and you can see it in the official Salus app. If it works there, your cover might not be supported. If you want to help to get it supported, open GitHub issue and add your sensor model number and output of this program. Be sure to run this program with --debug option.\n""") 161 | else: 162 | print("All sensor devices:") 163 | print(repr(sensor_devices)) 164 | 165 | for sensor_device_id in sensor_devices: 166 | print(f"Sensor device {sensor_device_id} status:") 167 | device = sensor_devices.get(sensor_device_id) 168 | print(repr(device)) 169 | 170 | 171 | if __name__ == "__main__": 172 | asyncio.run(main()) 173 | -------------------------------------------------------------------------------- /pyit600/gateway.py: -------------------------------------------------------------------------------- 1 | """Salus iT600 gateway API.""" 2 | 3 | import asyncio 4 | import json 5 | import logging 6 | from typing import Any, Dict, List, Optional, Callable, Awaitable 7 | 8 | import aiohttp 9 | import async_timeout 10 | 11 | from aiohttp import client_exceptions 12 | 13 | from .const import ( 14 | CURRENT_HVAC_HEAT, 15 | CURRENT_HVAC_HEAT_IDLE, 16 | CURRENT_HVAC_COOL, 17 | CURRENT_HVAC_COOL_IDLE, 18 | CURRENT_HVAC_IDLE, 19 | CURRENT_HVAC_OFF, 20 | HVAC_MODE_HEAT, 21 | HVAC_MODE_COOL, 22 | HVAC_MODE_OFF, 23 | HVAC_MODE_AUTO, 24 | PRESET_FOLLOW_SCHEDULE, 25 | PRESET_OFF, 26 | PRESET_PERMANENT_HOLD, 27 | PRESET_TEMPORARY_HOLD, 28 | PRESET_ECO, 29 | SUPPORT_FAN_MODE, 30 | SUPPORT_PRESET_MODE, 31 | SUPPORT_TARGET_TEMPERATURE, 32 | TEMP_CELSIUS, 33 | SUPPORT_OPEN, 34 | SUPPORT_CLOSE, 35 | SUPPORT_SET_POSITION, 36 | FAN_MODE_AUTO, 37 | FAN_MODE_HIGH, 38 | FAN_MODE_MEDIUM, 39 | FAN_MODE_LOW, 40 | FAN_MODE_OFF 41 | ) 42 | from .encryptor import IT600Encryptor 43 | from .exceptions import ( 44 | IT600AuthenticationError, 45 | IT600CommandError, 46 | IT600ConnectionError, 47 | ) 48 | from .models import GatewayDevice, ClimateDevice, BinarySensorDevice, SwitchDevice, CoverDevice, SensorDevice 49 | 50 | _LOGGER = logging.getLogger("pyit600") 51 | 52 | class IT600Gateway: 53 | def __init__( 54 | self, 55 | euid: str, 56 | host: str, 57 | port: int = 80, 58 | request_timeout: int = 5, 59 | session: aiohttp.client.ClientSession = None, 60 | debug: bool = False, 61 | ): 62 | self._encryptor = IT600Encryptor(euid) 63 | self._host = host 64 | self._port = port 65 | self._request_timeout = request_timeout 66 | self._debug = debug 67 | self._lock = asyncio.Lock() # Gateway supports very few concurrent requests 68 | 69 | """Initialize connection with the iT600 gateway.""" 70 | self._session = session 71 | self._close_session = False 72 | 73 | self._gateway_device: Optional[GatewayDevice] = None 74 | 75 | self._climate_devices: Dict[str, ClimateDevice] = {} 76 | self._climate_update_callbacks: List[Callable[[Any], Awaitable[None]]] = [] 77 | 78 | self._binary_sensor_devices: Dict[str, BinarySensorDevice] = {} 79 | self._binary_sensor_update_callbacks: List[Callable[[Any], Awaitable[None]]] = [] 80 | 81 | self._switch_devices: Dict[str, SwitchDevice] = {} 82 | self._switch_update_callbacks: List[Callable[[Any], Awaitable[None]]] = [] 83 | 84 | self._cover_devices: Dict[str, CoverDevice] = {} 85 | self._cover_update_callbacks: List[Callable[[Any], Awaitable[None]]] = [] 86 | 87 | self._sensor_devices: Dict[str, SensorDevice] = {} 88 | self._sensor_update_callbacks: List[Callable[[Any], Awaitable[None]]] = [] 89 | 90 | async def connect(self) -> str: 91 | """Public method for connecting to Salus universal gateway. 92 | On successful connection, returns gateway's mac address""" 93 | 94 | _LOGGER.debug("Trying to connect to gateway at %s", self._host) 95 | 96 | if self._session is None: 97 | self._session = aiohttp.ClientSession() 98 | self._close_session = True 99 | 100 | try: 101 | all_devices = await self._make_encrypted_request( 102 | "read", 103 | { 104 | "requestAttr": "readall" 105 | } 106 | ) 107 | 108 | gateway = next( 109 | filter(lambda x: len(x.get("sGateway", {}).get("NetworkLANMAC", "")) > 0, all_devices["id"]), 110 | None 111 | ) 112 | 113 | if gateway is None: 114 | raise IT600CommandError( 115 | "Error occurred while communicating with iT600 gateway: " 116 | "response did not contain gateway information" 117 | ) 118 | 119 | return gateway["sGateway"]["NetworkLANMAC"] 120 | except IT600ConnectionError as ae: 121 | try: 122 | with async_timeout.timeout(self._request_timeout): 123 | await self._session.get(f"http://{self._host}:{self._port}/") 124 | except Exception: 125 | raise IT600ConnectionError( 126 | "Error occurred while communicating with iT600 gateway: " 127 | "check if you have specified host/IP address correctly" 128 | ) from ae 129 | 130 | raise IT600AuthenticationError( 131 | "Error occurred while communicating with iT600 gateway: " 132 | "check if you have specified EUID correctly" 133 | ) from ae 134 | 135 | async def poll_status(self, send_callback=False) -> None: 136 | """Public method for polling the state of Salus iT600 devices.""" 137 | 138 | all_devices = await self._make_encrypted_request( 139 | "read", 140 | { 141 | "requestAttr": "readall" 142 | } 143 | ) 144 | 145 | try: 146 | gateway_devices = list( 147 | filter(lambda x: "sGateway" in x, all_devices["id"]) 148 | ) 149 | 150 | await self._refresh_gateway_device(gateway_devices, send_callback) 151 | except BaseException as e: 152 | _LOGGER.error("Failed to poll gateway device", exc_info=e) 153 | 154 | try: 155 | climate_devices = list( 156 | filter(lambda x: ("sIT600TH" in x) or ("sTherS" in x), all_devices["id"]) 157 | ) 158 | 159 | await self._refresh_climate_devices(climate_devices, send_callback) 160 | except BaseException as e: 161 | _LOGGER.error("Failed to poll climate devices", exc_info=e) 162 | 163 | try: 164 | binary_sensors = list( 165 | filter(lambda x: "sIASZS" in x or 166 | ("sBasicS" in x and 167 | "ModelIdentifier" in x["sBasicS"] and 168 | x["sBasicS"]["ModelIdentifier"] in ["it600MINITRV", "it600Receiver"]), all_devices["id"]) 169 | ) 170 | 171 | await self._refresh_binary_sensor_devices(binary_sensors, send_callback) 172 | except BaseException as e: 173 | _LOGGER.error("Failed to poll binary sensors", exc_info=e) 174 | 175 | try: 176 | sensors = list( 177 | filter(lambda x: "sTempS" in x, all_devices["id"]) 178 | ) 179 | 180 | await self._refresh_sensor_devices(sensors, send_callback) 181 | except BaseException as e: 182 | _LOGGER.error("Failed to poll sensors", exc_info=e) 183 | 184 | try: 185 | switches = list( 186 | filter(lambda x: "sOnOffS" in x, all_devices["id"]) 187 | ) 188 | 189 | await self._refresh_switch_devices(switches, send_callback) 190 | except BaseException as e: 191 | _LOGGER.error("Failed to poll switches", exc_info=e) 192 | 193 | try: 194 | covers = list( 195 | filter(lambda x: "sLevelS" in x, all_devices["id"]) 196 | ) 197 | 198 | await self._refresh_cover_devices(covers, send_callback) 199 | except BaseException as e: 200 | _LOGGER.error("Failed to poll covers", exc_info=e) 201 | 202 | async def _refresh_gateway_device(self, devices: List[Any], send_callback=False): 203 | local_device: Optional[GatewayDevice] = None 204 | 205 | if devices: 206 | status = await self._make_encrypted_request( 207 | "read", 208 | { 209 | "requestAttr": "deviceid", 210 | "id": [{"data": device["data"]} for device in devices] 211 | } 212 | ) 213 | 214 | for device_status in status["id"]: 215 | unique_id = device_status.get("sGateway", {}).get("NetworkLANMAC", None) 216 | 217 | if unique_id is None: 218 | continue 219 | 220 | model: Optional[str] = device_status.get("sGateway", {}).get("ModelIdentifier", None) 221 | 222 | try: 223 | local_device = GatewayDevice( 224 | name=model, 225 | unique_id=unique_id, 226 | data=device_status["data"], 227 | manufacturer=device_status.get("sBasicS", {}).get("ManufactureName", "SALUS"), 228 | model=model, 229 | sw_version=device_status.get("sOTA", {}).get("OTAFirmwareVersion_d", None) 230 | ) 231 | except BaseException as e: 232 | _LOGGER.error(f"Failed to poll gateway {unique_id}", exc_info=e) 233 | 234 | self._gateway_device = local_device 235 | _LOGGER.debug("Refreshed gateway device") 236 | 237 | async def _refresh_cover_devices(self, devices: List[Any], send_callback=False): 238 | local_devices = {} 239 | 240 | if devices: 241 | status = await self._make_encrypted_request( 242 | "read", 243 | { 244 | "requestAttr": "deviceid", 245 | "id": [{"data": device["data"]} for device in devices] 246 | } 247 | ) 248 | 249 | for device_status in status["id"]: 250 | unique_id = device_status.get("data", {}).get("UniID", None) 251 | 252 | if unique_id is None: 253 | continue 254 | 255 | try: 256 | if device_status.get("sButtonS", {}).get("Mode", None) == 0: 257 | continue # Skip endpoints which are disabled 258 | 259 | model: Optional[str] = device_status.get("DeviceL", {}).get("ModelIdentifier_i", None) 260 | 261 | current_position = device_status.get("sLevelS", {}).get("CurrentLevel", None) 262 | 263 | move_to_level_f = device_status.get("sLevelS", {}).get("MoveToLevel_f", None) 264 | 265 | if move_to_level_f is not None and len(move_to_level_f) >= 2: 266 | set_position = int(move_to_level_f[:2], 16) 267 | else: 268 | set_position = None 269 | 270 | device = CoverDevice( 271 | available=True if device_status.get("sZDOInfo", {}).get("OnlineStatus_i", 1) == 1 else False, 272 | name=json.loads(device_status.get("sZDO", {}).get("DeviceName", '{"deviceName": "Unknown"}'))["deviceName"], 273 | unique_id=unique_id, 274 | current_cover_position=current_position, 275 | is_opening=None if set_position is None else current_position < set_position, 276 | is_closing=None if set_position is None else current_position > set_position, 277 | is_closed=True if current_position == 0 else False, 278 | supported_features=SUPPORT_OPEN | SUPPORT_CLOSE | SUPPORT_SET_POSITION, 279 | device_class=None, 280 | data=device_status["data"], 281 | manufacturer=device_status.get("sBasicS", {}).get("ManufactureName", "SALUS"), 282 | model=model, 283 | sw_version=device_status.get("sZDO", {}).get("FirmwareVersion", None) 284 | ) 285 | 286 | local_devices[device.unique_id] = device 287 | 288 | if send_callback: 289 | self._cover_devices[device.unique_id] = device 290 | await self._send_cover_update_callback(device_id=device.unique_id) 291 | except BaseException as e: 292 | _LOGGER.error(f"Failed to poll device {unique_id}", exc_info=e) 293 | 294 | self._cover_devices = local_devices 295 | _LOGGER.debug("Refreshed %s cover devices", len(self._cover_devices)) 296 | 297 | async def _refresh_switch_devices(self, devices: List[Any], send_callback=False): 298 | local_devices = {} 299 | 300 | if devices: 301 | status = await self._make_encrypted_request( 302 | "read", 303 | { 304 | "requestAttr": "deviceid", 305 | "id": [{"data": device["data"]} for device in devices] 306 | } 307 | ) 308 | 309 | for device_status in status["id"]: 310 | unique_id = device_status.get("data", {}).get("UniID", None) 311 | 312 | if unique_id is None: 313 | continue 314 | else: 315 | unique_id = unique_id + "_" + str(device_status["data"]["Endpoint"]) # Double switches have a different endpoint id, but the same device id 316 | 317 | try: 318 | if device_status.get("sLevelS", None) is not None: 319 | continue # Skip roller shutter endpoint in combined roller shutter/relay device 320 | 321 | is_on: Optional[bool] = device_status.get("sOnOffS", {}).get("OnOff", None) 322 | 323 | if is_on is None: 324 | continue 325 | 326 | model: Optional[str] = device_status.get("DeviceL", {}).get("ModelIdentifier_i", None) 327 | 328 | device = SwitchDevice( 329 | available=True if device_status.get("sZDOInfo", {}).get("OnlineStatus_i", 1) == 1 else False, 330 | name=json.loads(device_status.get("sZDO", {}).get("DeviceName", '{"deviceName": ' + json.dumps(unique_id) + '}'))["deviceName"], 331 | unique_id=unique_id, 332 | is_on=True if is_on == 1 else False, 333 | device_class="outlet" if (model == "SP600" or model == "SPE600") else "switch", 334 | data=device_status["data"], 335 | manufacturer=device_status.get("sBasicS", {}).get("ManufactureName", "SALUS"), 336 | model=model, 337 | sw_version=device_status.get("sZDO", {}).get("FirmwareVersion", None) 338 | ) 339 | 340 | local_devices[device.unique_id] = device 341 | 342 | if send_callback: 343 | self._switch_devices[device.unique_id] = device 344 | await self._send_switch_update_callback(device_id=device.unique_id) 345 | except BaseException as e: 346 | _LOGGER.error(f"Failed to poll device {unique_id}", exc_info=e) 347 | 348 | self._switch_devices = local_devices 349 | _LOGGER.debug("Refreshed %s sensor devices", len(self._switch_devices)) 350 | 351 | async def _refresh_sensor_devices(self, devices: List[Any], send_callback=False): 352 | local_devices = {} 353 | 354 | if devices: 355 | status = await self._make_encrypted_request( 356 | "read", 357 | { 358 | "requestAttr": "deviceid", 359 | "id": [{"data": device["data"]} for device in devices] 360 | } 361 | ) 362 | 363 | for device_status in status["id"]: 364 | unique_id = device_status.get("data", {}).get("UniID", None) 365 | 366 | if unique_id is None: 367 | continue 368 | 369 | try: 370 | temperature: Optional[int] = device_status.get("sTempS", {}).get("MeasuredValue_x100", None) 371 | 372 | if temperature is None: 373 | continue 374 | 375 | unique_id = unique_id + "_temp" # Some sensors also measure temperature besides their primary function (eg. SW600) 376 | 377 | model: Optional[str] = device_status.get("DeviceL", {}).get("ModelIdentifier_i", None) 378 | 379 | device = SensorDevice( 380 | available=True if device_status.get("sZDOInfo", {}).get("OnlineStatus_i", 1) == 1 else False, 381 | name=json.loads(device_status.get("sZDO", {}).get("DeviceName", '{"deviceName": "Unknown"}'))["deviceName"], 382 | unique_id=unique_id, 383 | state=(temperature / 100), 384 | unit_of_measurement=TEMP_CELSIUS, 385 | device_class="temperature", 386 | data=device_status["data"], 387 | manufacturer=device_status.get("sBasicS", {}).get("ManufactureName", "SALUS"), 388 | model=model, 389 | sw_version=device_status.get("sZDO", {}).get("FirmwareVersion", None) 390 | ) 391 | 392 | local_devices[device.unique_id] = device 393 | 394 | if send_callback: 395 | self._sensor_devices[device.unique_id] = device 396 | await self._send_sensor_update_callback(device_id=device.unique_id) 397 | except BaseException as e: 398 | _LOGGER.error(f"Failed to poll device {unique_id}", exc_info=e) 399 | 400 | self._sensor_devices = local_devices 401 | _LOGGER.debug("Refreshed %s sensor devices", len(self._sensor_devices)) 402 | 403 | async def _refresh_binary_sensor_devices(self, devices: List[Any], send_callback=False): 404 | local_devices = {} 405 | 406 | if devices: 407 | status = await self._make_encrypted_request( 408 | "read", 409 | { 410 | "requestAttr": "deviceid", 411 | "id": [{"data": device["data"]} for device in devices] 412 | } 413 | ) 414 | 415 | for device_status in status["id"]: 416 | unique_id = device_status.get("data", {}).get("UniID", None) 417 | 418 | if unique_id is None: 419 | continue 420 | 421 | try: 422 | model: Optional[str] = device_status.get("DeviceL", {}).get("ModelIdentifier_i", None) 423 | if model in ["it600MINITRV", "it600Receiver"]: 424 | is_on: Optional[bool] = device_status.get("sIT600I", {}).get("RelayStatus", None) 425 | else: 426 | is_on: Optional[bool] = device_status.get("sIASZS", {}).get("ErrorIASZSAlarmed1", None) 427 | 428 | if is_on is None: 429 | continue 430 | 431 | if model == "SB600": 432 | continue # Skip button 433 | 434 | device = BinarySensorDevice( 435 | available=True if device_status.get("sZDOInfo", {}).get("OnlineStatus_i", 1) == 1 else False, 436 | name=json.loads(device_status.get("sZDO", {}).get("DeviceName", '{"deviceName": "Unknown"}'))["deviceName"], 437 | unique_id=device_status["data"]["UniID"], 438 | is_on=True if is_on == 1 else False, 439 | device_class="window" if (model == "SW600" or model == "OS600") else 440 | "moisture" if model == "WLS600" else 441 | "smoke" if model == "SmokeSensor-EM" else 442 | "valve" if model == "it600MINITRV" else 443 | "receiver" if model == "it600Receiver" else 444 | None, 445 | data=device_status["data"], 446 | manufacturer=device_status.get("sBasicS", {}).get("ManufactureName", "SALUS"), 447 | model=model, 448 | sw_version=device_status.get("sZDO", {}).get("FirmwareVersion", None) 449 | ) 450 | 451 | local_devices[device.unique_id] = device 452 | 453 | if send_callback: 454 | self._binary_sensor_devices[device.unique_id] = device 455 | await self._send_binary_sensor_update_callback(device_id=device.unique_id) 456 | except BaseException as e: 457 | _LOGGER.error(f"Failed to poll device {unique_id}", exc_info=e) 458 | 459 | self._binary_sensor_devices = local_devices 460 | _LOGGER.debug("Refreshed %s binary sensor devices", len(self._binary_sensor_devices)) 461 | 462 | async def _refresh_climate_devices(self, devices: List[Any], send_callback=False): 463 | local_devices = {} 464 | 465 | if devices: 466 | status = await self._make_encrypted_request( 467 | "read", 468 | { 469 | "requestAttr": "deviceid", 470 | "id": [{"data": device["data"]} for device in devices] 471 | } 472 | ) 473 | 474 | for device_status in status["id"]: 475 | unique_id = device_status.get("data", {}).get("UniID", None) 476 | 477 | if unique_id is None: 478 | continue 479 | 480 | try: 481 | model: Optional[str] = device_status.get("DeviceL", {}).get("ModelIdentifier_i", None) 482 | 483 | th = device_status.get("sIT600TH", None) 484 | ther = device_status.get("sTherS", None) 485 | scomm = device_status.get("sComm", None) 486 | sfans = device_status.get("sFanS", None) 487 | 488 | global_args = { 489 | "available": True if device_status.get("sZDOInfo", {}).get("OnlineStatus_i", 1) == 1 else False, 490 | "name": json.loads(device_status.get("sZDO", {}).get("DeviceName", '{"deviceName": "Unknown"}'))["deviceName"], 491 | "unique_id": unique_id, 492 | "temperature_unit": TEMP_CELSIUS, # API always reports temperature as celsius 493 | "precision": 0.1, 494 | "device_class": "temperature", 495 | "data": device_status["data"], 496 | "manufacturer": device_status.get("sBasicS", {}).get("ManufactureName", "SALUS"), 497 | "model": model, 498 | "sw_version": device_status.get("sZDO", {}).get("FirmwareVersion", None), 499 | } 500 | 501 | if th is not None: 502 | current_humidity: Optional[float] = None 503 | 504 | if model is not None and "SQ610" in model: 505 | current_humidity = th.get("SunnySetpoint_x100", None) # Quantum thermostats store humidity there, other thermostats store there one of the setpoint temperatures 506 | 507 | device = ClimateDevice( 508 | **global_args, 509 | current_humidity=current_humidity, 510 | current_temperature=th["LocalTemperature_x100"] / 100, 511 | target_temperature=th["HeatingSetpoint_x100"] / 100, 512 | max_temp=th.get("MaxHeatSetpoint_x100", 3500) / 100, 513 | min_temp=th.get("MinHeatSetpoint_x100", 500) / 100, 514 | hvac_mode=HVAC_MODE_OFF if th["HoldType"] == 7 else HVAC_MODE_HEAT if th["HoldType"] == 2 else HVAC_MODE_AUTO, 515 | hvac_action=CURRENT_HVAC_OFF if th["HoldType"] == 7 else CURRENT_HVAC_IDLE if th["RunningState"] % 2 == 0 else CURRENT_HVAC_HEAT, # RunningState 0 or 128 => idle, 1 or 129 => heating 516 | hvac_modes=[HVAC_MODE_OFF, HVAC_MODE_HEAT, HVAC_MODE_AUTO], 517 | preset_mode=PRESET_OFF if th["HoldType"] == 7 else PRESET_PERMANENT_HOLD if th["HoldType"] == 2 else PRESET_FOLLOW_SCHEDULE, 518 | preset_modes=[PRESET_FOLLOW_SCHEDULE, PRESET_PERMANENT_HOLD, PRESET_OFF], 519 | fan_mode=None, 520 | fan_modes=None, 521 | locked=None, 522 | supported_features=SUPPORT_TARGET_TEMPERATURE | SUPPORT_PRESET_MODE, 523 | ) 524 | elif ther is not None and scomm is not None and sfans is not None: 525 | is_heating: bool = (ther["SystemMode"] == 4) 526 | fan_mode: int = sfans.get("FanMode", 5) 527 | 528 | device = ClimateDevice( 529 | **global_args, 530 | current_humidity=None, 531 | current_temperature=ther["LocalTemperature_x100"] / 100, 532 | target_temperature=(ther["HeatingSetpoint_x100"] / 100) if is_heating else (ther["CoolingSetpoint_x100"] / 100), 533 | max_temp=(ther.get("MaxHeatSetpoint_x100", 4000) / 100) if is_heating else (ther.get("MaxCoolSetpoint_x100", 4000) / 100), 534 | min_temp=(ther.get("MinHeatSetpoint_x100", 500) / 100) if is_heating else (ther.get("MinCoolSetpoint_x100", 500) / 100), 535 | hvac_mode=HVAC_MODE_HEAT if ther["SystemMode"] == 4 else HVAC_MODE_COOL if ther["SystemMode"] == 3 else HVAC_MODE_AUTO, 536 | hvac_action=CURRENT_HVAC_OFF if scomm["HoldType"] == 7 else CURRENT_HVAC_IDLE if ther["RunningState"] == 0 else CURRENT_HVAC_HEAT if is_heating and ther["RunningState"] == 33 else CURRENT_HVAC_HEAT_IDLE if is_heating else CURRENT_HVAC_COOL if ther["RunningState"] == 66 else CURRENT_HVAC_COOL_IDLE, 537 | hvac_modes=[HVAC_MODE_HEAT, HVAC_MODE_COOL, HVAC_MODE_AUTO], 538 | preset_mode=PRESET_OFF if scomm["HoldType"] == 7 else PRESET_PERMANENT_HOLD if scomm["HoldType"] == 2 else PRESET_ECO if scomm["HoldType"] == 10 else PRESET_TEMPORARY_HOLD if scomm["HoldType"] == 1 else PRESET_FOLLOW_SCHEDULE, 539 | preset_modes=[PRESET_OFF, PRESET_PERMANENT_HOLD, PRESET_ECO, PRESET_TEMPORARY_HOLD, PRESET_FOLLOW_SCHEDULE], 540 | fan_mode=FAN_MODE_OFF if fan_mode == 0 else FAN_MODE_HIGH if fan_mode == 3 else FAN_MODE_MEDIUM if fan_mode == 2 else FAN_MODE_LOW if fan_mode == 1 else FAN_MODE_AUTO, # fan_mode == 5 => FAN_MODE_AUTO 541 | fan_modes=[FAN_MODE_AUTO, FAN_MODE_HIGH, FAN_MODE_MEDIUM, FAN_MODE_LOW, FAN_MODE_OFF], 542 | locked=True if device_status.get("sTherUIS", {}).get("LockKey", 0) == 1 else False, 543 | supported_features=SUPPORT_TARGET_TEMPERATURE | SUPPORT_PRESET_MODE | SUPPORT_FAN_MODE, 544 | ) 545 | else: 546 | continue 547 | 548 | local_devices[device.unique_id] = device 549 | 550 | if send_callback: 551 | self._climate_devices[device.unique_id] = device 552 | await self._send_climate_update_callback(device_id=device.unique_id) 553 | except BaseException as e: 554 | _LOGGER.error(f"Failed to poll device {unique_id}", exc_info=e) 555 | 556 | self._climate_devices = local_devices 557 | _LOGGER.debug("Refreshed %s climate devices", len(self._climate_devices)) 558 | 559 | async def _send_climate_update_callback(self, device_id: str) -> None: 560 | """Internal method to notify all update callback subscribers.""" 561 | 562 | if self._climate_update_callbacks: 563 | for update_callback in self._climate_update_callbacks: 564 | await update_callback(device_id=device_id) 565 | else: 566 | _LOGGER.error("Callback for climate updates has not been set") 567 | 568 | async def _send_binary_sensor_update_callback(self, device_id: str) -> None: 569 | """Internal method to notify all update callback subscribers.""" 570 | 571 | if self._binary_sensor_update_callbacks: 572 | for update_callback in self._binary_sensor_update_callbacks: 573 | await update_callback(device_id=device_id) 574 | else: 575 | _LOGGER.error("Callback for binary sensor updates has not been set") 576 | 577 | async def _send_switch_update_callback(self, device_id: str) -> None: 578 | """Internal method to notify all update callback subscribers.""" 579 | 580 | if self._switch_update_callbacks: 581 | for update_callback in self._switch_update_callbacks: 582 | await update_callback(device_id=device_id) 583 | else: 584 | _LOGGER.error("Callback for switch updates has not been set") 585 | 586 | async def _send_cover_update_callback(self, device_id: str) -> None: 587 | """Internal method to notify all update callback subscribers.""" 588 | 589 | if self._cover_update_callbacks: 590 | for update_callback in self._cover_update_callbacks: 591 | await update_callback(device_id=device_id) 592 | else: 593 | _LOGGER.error("Callback for cover updates has not been set") 594 | 595 | async def _send_sensor_update_callback(self, device_id: str) -> None: 596 | """Internal method to notify all update callback subscribers.""" 597 | 598 | if self._sensor_update_callbacks: 599 | for update_callback in self._sensor_update_callbacks: 600 | await update_callback(device_id=device_id) 601 | else: 602 | _LOGGER.error("Callback for sensor updates has not been set") 603 | 604 | def get_gateway_device(self) -> Optional[GatewayDevice]: 605 | """Public method to return gateway device.""" 606 | 607 | return self._gateway_device 608 | 609 | def get_climate_devices(self) -> Dict[str, ClimateDevice]: 610 | """Public method to return the state of all Salus IT600 climate devices.""" 611 | 612 | return self._climate_devices 613 | 614 | def get_climate_device(self, device_id: str) -> Optional[ClimateDevice]: 615 | """Public method to return the state of the specified climate device.""" 616 | 617 | return self._climate_devices.get(device_id) 618 | 619 | def get_binary_sensor_devices(self) -> Dict[str, BinarySensorDevice]: 620 | """Public method to return the state of all Salus IT600 binary sensor devices.""" 621 | 622 | return self._binary_sensor_devices 623 | 624 | def get_binary_sensor_device(self, device_id: str) -> Optional[BinarySensorDevice]: 625 | """Public method to return the state of the specified binary sensor device.""" 626 | 627 | return self._binary_sensor_devices.get(device_id) 628 | 629 | def get_switch_devices(self) -> Dict[str, SwitchDevice]: 630 | """Public method to return the state of all Salus IT600 switch devices.""" 631 | 632 | return self._switch_devices 633 | 634 | def get_switch_device(self, device_id: str) -> Optional[SwitchDevice]: 635 | """Public method to return the state of the specified switch device.""" 636 | 637 | return self._switch_devices.get(device_id) 638 | 639 | def get_cover_devices(self) -> Dict[str, CoverDevice]: 640 | """Public method to return the state of all Salus IT600 cover devices.""" 641 | 642 | return self._cover_devices 643 | 644 | def get_cover_device(self, device_id: str) -> Optional[CoverDevice]: 645 | """Public method to return the state of the specified cover device.""" 646 | 647 | return self._cover_devices.get(device_id) 648 | 649 | def get_sensor_devices(self) -> Dict[str, SensorDevice]: 650 | """Public method to return the state of all Salus IT600 sensor devices.""" 651 | 652 | return self._sensor_devices 653 | 654 | def get_sensor_device(self, device_id: str) -> Optional[SensorDevice]: 655 | """Public method to return the state of the specified sensor device.""" 656 | 657 | return self._sensor_devices.get(device_id) 658 | 659 | async def set_cover_position(self, device_id: str, position: int) -> None: 660 | """Public method to set position/level (where 0 means closed and 100 is fully open) on the specified cover device.""" 661 | 662 | if position < 0 or position > 100: 663 | raise ValueError("position must be between 0 and 100 (both bounds inclusive)") 664 | 665 | device = self.get_cover_device(device_id) 666 | 667 | if device is None: 668 | _LOGGER.error("Cannot set cover position: cover device not found with the specified id: %s", device_id) 669 | return 670 | 671 | await self._make_encrypted_request( 672 | "write", 673 | { 674 | "requestAttr": "write", 675 | "id": [ 676 | { 677 | "data": device.data, 678 | "sLevelS": { 679 | "SetMoveToLevel": f"{format(position, '02x')}FFFF" 680 | }, 681 | } 682 | ], 683 | }, 684 | ) 685 | 686 | async def open_cover(self, device_id: str) -> None: 687 | """Public method to open the specified cover device.""" 688 | 689 | await self.set_cover_position(device_id, 100) 690 | 691 | async def close_cover(self, device_id: str) -> None: 692 | """Public method to close the specified cover device.""" 693 | 694 | await self.set_cover_position(device_id, 0) 695 | 696 | async def turn_on_switch_device(self, device_id: str) -> None: 697 | """Public method to turn on the specified switch device.""" 698 | 699 | device = self.get_switch_device(device_id) 700 | 701 | if device is None: 702 | _LOGGER.error("Cannot turn on: switch device not found with the specified id: %s", device_id) 703 | return 704 | 705 | await self._make_encrypted_request( 706 | "write", 707 | { 708 | "requestAttr": "write", 709 | "id": [ 710 | { 711 | "data": device.data, 712 | "sOnOffS": { 713 | "SetOnOff": 1 714 | }, 715 | } 716 | ], 717 | }, 718 | ) 719 | 720 | async def turn_off_switch_device(self, device_id: str) -> None: 721 | """Public method to turn off the specified switch device.""" 722 | 723 | device = self.get_switch_device(device_id) 724 | 725 | if device is None: 726 | _LOGGER.error("Cannot turn off: switch device not found with the specified id: %s", device_id) 727 | return 728 | 729 | await self._make_encrypted_request( 730 | "write", 731 | { 732 | "requestAttr": "write", 733 | "id": [ 734 | { 735 | "data": device.data, 736 | "sOnOffS": { 737 | "SetOnOff": 0 738 | }, 739 | } 740 | ], 741 | }, 742 | ) 743 | 744 | async def set_climate_device_preset(self, device_id: str, preset: str) -> None: 745 | """Public method for setting the hvac preset.""" 746 | 747 | device = self.get_climate_device(device_id) 748 | 749 | if device is None: 750 | _LOGGER.error("Cannot set mode: climate device not found with the specified id: %s", device_id) 751 | return 752 | 753 | if device.model == 'FC600': 754 | request_data = { "sComm": { "SetHoldType": 7 if preset == PRESET_OFF else 10 if preset == PRESET_ECO else 2 if preset == PRESET_PERMANENT_HOLD else 1 if preset == PRESET_TEMPORARY_HOLD else 0 } } 755 | else: 756 | request_data = { "sIT600TH": { "SetHoldType": 7 if preset == PRESET_OFF else 2 if preset == PRESET_PERMANENT_HOLD else 0 } } 757 | 758 | await self._make_encrypted_request( 759 | "write", 760 | { 761 | "requestAttr": "write", 762 | "id": [ 763 | { 764 | "data": device.data, 765 | **request_data, 766 | } 767 | ], 768 | }, 769 | ) 770 | 771 | async def set_climate_device_mode(self, device_id: str, mode: str) -> None: 772 | """Public method for setting the hvac mode.""" 773 | 774 | device = self.get_climate_device(device_id) 775 | 776 | if device is None: 777 | _LOGGER.error("Cannot set mode: device not found with the specified id: %s", device_id) 778 | return 779 | 780 | if device.model == 'FC600': 781 | request_data = { "sTherS": { "SetSystemMode": 4 if mode == HVAC_MODE_HEAT else 3 if mode == HVAC_MODE_COOL else HVAC_MODE_AUTO } } 782 | else: 783 | request_data = { "sIT600TH": { "SetHoldType": 7 if mode == HVAC_MODE_OFF else 0 } } 784 | 785 | await self._make_encrypted_request( 786 | "write", 787 | { 788 | "requestAttr": "write", 789 | "id": [ 790 | { 791 | "data": device.data, 792 | **request_data, 793 | } 794 | ], 795 | }, 796 | ) 797 | 798 | async def set_climate_device_fan_mode(self, device_id: str, mode: str) -> None: 799 | """Public method for setting the hvac fan mode.""" 800 | 801 | device = self.get_climate_device(device_id) 802 | 803 | if device is None: 804 | _LOGGER.error("Cannot set fan mode: device not found with the specified id: %s", device_id) 805 | return 806 | 807 | request_data = { "sFanS": { "FanMode": 5 if mode == FAN_MODE_AUTO else 3 if mode == FAN_MODE_HIGH else 2 if mode == FAN_MODE_MID else 1 if mode == FAN_MODE_LOW else 0 } } 808 | 809 | await self._make_encrypted_request( 810 | "write", 811 | { 812 | "requestAttr": "write", 813 | "id": [ 814 | { 815 | "data": device.data, 816 | **request_data, 817 | } 818 | ], 819 | }, 820 | ) 821 | 822 | async def set_climate_device_locked(self, device_id: str, locked: bool) -> None: 823 | """Public method for setting the hvac locked status.""" 824 | 825 | device = self.get_climate_device(device_id) 826 | 827 | if device is None: 828 | _LOGGER.error("Cannot set locked status: device not found with the specified id: %s", device_id) 829 | return 830 | 831 | request_data = { "sTherUIS": { "LockKey": 1 if locked else 0 } } 832 | 833 | await self._make_encrypted_request( 834 | "write", 835 | { 836 | "requestAttr": "write", 837 | "id": [ 838 | { 839 | "data": device.data, 840 | **request_data, 841 | } 842 | ], 843 | }, 844 | ) 845 | 846 | async def set_climate_device_temperature(self, device_id: str, setpoint_celsius: float) -> None: 847 | """Public method for setting the temperature.""" 848 | 849 | device = self.get_climate_device(device_id) 850 | 851 | if device is None: 852 | _LOGGER.error("Cannot set mode: climate device not found with the specified id: %s", device_id) 853 | return 854 | 855 | if device.model == 'FC600': 856 | if device.hvac_mode == HVAC_MODE_COOL: 857 | request_data = { "sTherS": { "SetCoolingSetpoint_x100": int(self.round_to_half(setpoint_celsius) * 100) } } 858 | else: 859 | request_data = { "sTherS": { "SetHeatingSetpoint_x100": int(self.round_to_half(setpoint_celsius) * 100) } } 860 | else: 861 | request_data = { "sIT600TH": { "SetHeatingSetpoint_x100": int(self.round_to_half(setpoint_celsius) * 100) } } 862 | 863 | await self._make_encrypted_request( 864 | "write", 865 | { 866 | "requestAttr": "write", 867 | "id": [ 868 | { 869 | "data": device.data, 870 | **request_data, 871 | } 872 | ], 873 | }, 874 | ) 875 | 876 | @staticmethod 877 | def round_to_half(number: float) -> float: 878 | """Rounds number to half of the integer (eg. 1.01 -> 1, 1.4 -> 1.5, 1.8 -> 2)""" 879 | 880 | return round(number * 2) / 2 881 | 882 | async def add_climate_update_callback(self, method: Callable[[Any], Awaitable[None]]) -> None: 883 | """Public method to add a climate callback subscriber.""" 884 | 885 | self._climate_update_callbacks.append(method) 886 | 887 | async def add_binary_sensor_update_callback(self, method: Callable[[Any], Awaitable[None]]) -> None: 888 | """Public method to add a binary sensor callback subscriber.""" 889 | 890 | self._binary_sensor_update_callbacks.append(method) 891 | 892 | async def add_switch_update_callback(self, method: Callable[[Any], Awaitable[None]]) -> None: 893 | """Public method to add a switch callback subscriber.""" 894 | 895 | self._switch_update_callbacks.append(method) 896 | 897 | async def add_cover_update_callback(self, method: Callable[[Any], Awaitable[None]]) -> None: 898 | """Public method to add a cover callback subscriber.""" 899 | 900 | self._cover_update_callbacks.append(method) 901 | 902 | async def add_sensor_update_callback(self, method: Callable[[Any], Awaitable[None]]) -> None: 903 | """Public method to add a sensor callback subscriber.""" 904 | 905 | self._sensor_update_callbacks.append(method) 906 | 907 | async def _make_encrypted_request(self, command: str, request_body: dict) -> Any: 908 | """Makes encrypted Salus iT600 json request, decrypts and returns response.""" 909 | 910 | async with self._lock: 911 | if self._session is None: 912 | self._session = aiohttp.ClientSession() 913 | self._close_session = True 914 | 915 | try: 916 | request_url = f"http://{self._host}:{self._port}/deviceid/{command}" 917 | request_body_json = json.dumps(request_body) 918 | 919 | if self._debug: 920 | _LOGGER.debug("Gateway request: POST %s\n%s\n", request_url, request_body_json) 921 | 922 | with async_timeout.timeout(self._request_timeout): 923 | resp = await self._session.post( 924 | request_url, 925 | data=self._encryptor.encrypt(request_body_json), 926 | headers={"content-type": "application/json"}, 927 | ) 928 | response_bytes = await resp.read() 929 | response_json_string = self._encryptor.decrypt(response_bytes) 930 | 931 | if self._debug: 932 | _LOGGER.debug("Gateway response:\n%s\n", response_json_string) 933 | 934 | response_json = json.loads(response_json_string) 935 | 936 | if not response_json["status"] == "success": 937 | repr_request_body = repr(request_body) 938 | 939 | _LOGGER.error("%s failed: %s", command, repr_request_body) 940 | raise IT600CommandError( 941 | f"iT600 gateway rejected '{command}' command with content '{repr_request_body}'" 942 | ) 943 | 944 | return response_json 945 | except asyncio.TimeoutError as e: 946 | _LOGGER.error("Timeout while connecting to gateway: %s", e) 947 | raise IT600ConnectionError( 948 | "Error occurred while communicating with iT600 gateway: timeout" 949 | ) from e 950 | except client_exceptions.ClientConnectorError as e: 951 | raise IT600ConnectionError( 952 | "Error occurred while communicating with iT600 gateway: " 953 | "check if you have specified host/IP address correctly" 954 | ) from e 955 | except Exception as e: 956 | _LOGGER.error("Exception. %s / %s", type(e), repr(e.args), e) 957 | raise IT600CommandError( 958 | "Unknown error occurred while communicating with iT600 gateway" 959 | ) from e 960 | 961 | async def close(self) -> None: 962 | """Close open client session.""" 963 | 964 | if self._session and self._close_session: 965 | await self._session.close() 966 | 967 | async def __aenter__(self) -> "IT600Gateway": 968 | """Async enter.""" 969 | 970 | return self 971 | 972 | async def __aexit__(self, *exc_info) -> None: 973 | """Async exit.""" 974 | 975 | await self.close() 976 | --------------------------------------------------------------------------------