From 91216bb261bfd8165715d97bf5fd73213d739f08 Mon Sep 17 00:00:00 2001 From: Valentin Dusollier Date: Sun, 18 Jun 2023 14:18:05 +0200 Subject: [PATCH 1/4] Add Contrib support for Inverter Heat Pump such as Fairland IPHR55 --- testcontrib.py | 78 ++++++++- tinytuya/Contrib/InverterHeatPumpDevice.py | 187 +++++++++++++++++++++ tinytuya/Contrib/README.md | 23 +++ tinytuya/Contrib/__init__.py | 3 +- 4 files changed, 289 insertions(+), 2 deletions(-) create mode 100644 tinytuya/Contrib/InverterHeatPumpDevice.py diff --git a/testcontrib.py b/testcontrib.py index 6fb30004..31411955 100644 --- a/testcontrib.py +++ b/testcontrib.py @@ -15,6 +15,82 @@ print(" * %s" % i) print(" Test ThermostatDevice init(): ") -d = Contrib.ThermostatDevice( 'abcdefghijklmnop123456', '172.28.321.475', '1234567890123abc' ) +d = Contrib.ThermostatDevice("abcdefghijklmnop123456", "172.28.321.475", "1234567890123abc") + +import time +import os +from dotenv import load_dotenv + +load_dotenv() + +IHP_DEVICEID = os.getenv("IHP_DEVICEID", None) +IHP_DEVICEIP = os.getenv("IHP_DEVICEIP", None) +IHP_DEVICEKEY = os.getenv("IHP_DEVICEKEY", None) +IHP_DEVICEVERS = os.getenv("IHP_DEVICEVERS", None) + +if IHP_DEVICEID and IHP_DEVICEIP and IHP_DEVICEKEY and IHP_DEVICEVERS: + print(" Test InverterHeatPumpDevice: ") + print(" * Device ID: %s" % IHP_DEVICEID) + print(" * Device IP: %s" % IHP_DEVICEIP) + print(" * Device Key: %s" % IHP_DEVICEKEY) + print(" * Device Version: %s" % IHP_DEVICEVERS) + print() + + device = Contrib.InverterHeatPumpDevice( + dev_id=IHP_DEVICEID, address=IHP_DEVICEIP, local_key=IHP_DEVICEKEY, version=IHP_DEVICEVERS + ) + + is_on = device.is_on() + unit = device.get_unit() + target_water_temp = device.get_target_water_temp() + lower_limit_target_water_temp = device.get_lower_limit_target_water_temp() + is_silence_mode = device.is_silence_mode() + + print(" * is_on(): %r" % is_on) + print(" * get_unit(): %r" % unit) + print(" * get_inlet_water_temp(): %r" % device.get_inlet_water_temp()) + print(" * get_target_water_temp(): %r" % target_water_temp) + print(" * get_lower_limit_target_water_temp(): %r" % lower_limit_target_water_temp) + print(" * get_upper_limit_target_water_temp(): %r" % device.get_upper_limit_target_water_temp()) + print(" * get_heating_capacity_percent(): %r" % device.get_heating_capacity_percent()) + print(" * get_mode(): %r" % device.get_mode()) + print(" * get_mode(raw=True): %r" % device.get_mode(raw=True)) + print(" * get_fault(): %r" % device.get_fault()) + print(" * get_fault(raw=True): %r" % device.get_fault(raw=True)) + print(" * is_silence_mode(): %r" % is_silence_mode) + + time.sleep(10) + + print(" Toggle ON/OFF") + for power_state in [not is_on, is_on]: + print(" * Turning %s" % ("ON" if power_state else "OFF")) + device.turn_on() if power_state else device.turn_off() + time.sleep(5) + print(" * is_on(): %r" % device.is_on()) + time.sleep(10) + + print(" Toggle unit") + for unit_value in [not unit.value, unit.value]: + print(" * Setting unit to %r" % Contrib.TemperatureUnit(unit_value)) + device.set_unit(Contrib.TemperatureUnit(unit_value)) + time.sleep(5) + print(" * get_unit(): %r" % device.get_unit()) + time.sleep(5) + + print(" Set target water temperature to lower limit and previous value") + for target_water_temp_value in [lower_limit_target_water_temp, target_water_temp]: + print(" * Setting target water temperature to %r" % target_water_temp_value) + device.set_target_water_temp(target_water_temp_value) + time.sleep(5) + print(" * get_target_water_temp(): %r" % device.get_target_water_temp()) + time.sleep(5) + + print(" Toggle silence mode") + for silence_mode in [not is_silence_mode, is_silence_mode]: + print(" * Setting silence mode to %r" % silence_mode) + device.set_silence_mode(silence_mode) + time.sleep(5) + print(" * is_silence_mode(): %r" % device.is_silence_mode()) + time.sleep(5) exit() diff --git a/tinytuya/Contrib/InverterHeatPumpDevice.py b/tinytuya/Contrib/InverterHeatPumpDevice.py new file mode 100644 index 00000000..127ac67f --- /dev/null +++ b/tinytuya/Contrib/InverterHeatPumpDevice.py @@ -0,0 +1,187 @@ +""" + Python module to interface with Tuya WiFi smart inverter heat pump + + Author: Valentin Dusollier (https://github.com/valentindusollier) + Tested: Fairland Inverter+ 21kW (IPHR55) + + Local Control Classes + InverterHeatPumpDevice(...) + See Device() for constructor arguments + + Functions + InverterHeatPumpDevice: + is_on() # Returns True if the inverter is on + get_unit() # Returns the unit of the temperature + # (TemperatureUnit.CELSIUS or TemperatureUnit.FAHRENHEIT) + get_inlet_water_temp() # Returns the inlet water temperature + get_target_water_temp() # Returns the target water temperature + get_lower_limit_target_water_temp() # Returns the lower limit of the target water temperature + get_upper_limit_target_water_temp() # Returns the upper limit of the target water temperature + get_heating_capacity_percent() # Returns the heating capacity in percent + get_mode(raw=True/False) # Returns the current InverterHeatPumpMode(Enum) if raw=False + # (default value), otherwise returns the string mode + get_fault(raw=True/False) # Returns the current InverterHeatPumpFault(Enum) if raw=False + # (default value), otherwise returns the integer fault code + is_silence_mode() # Returns True if the silence mode is on + + set_unit(TemperatureUnit) # Set the unit of the temperature + # (TemperatureUnit.CELSIUS or TemperatureUnit.FAHRENHEIT) + set_target_water_temp(integer) # Set the target water temperature. Must be between + # get_lower_limit_target_water_temp() and + # get_upper_limit_target_water_temp() + set_silence_mode(True/False) # Set the silence mode on (True) or off (False) + + Inherited + json = status() # returns json payload + set_version(version) # 3.1 [default] or 3.3 + set_socketPersistent(False/True) # False [default] or True + set_socketNODELAY(False/True) # False or True [default] + set_socketRetryLimit(integer) # retry count limit [default 5] + set_socketTimeout(timeout) # set connection timeout in seconds [default 5] + set_dpsUsed(dps_to_request) # add data points (DPS) to request + add_dps_to_request(index) # add data point (DPS) index set to None + set_retry(retry=True) # retry if response payload is truncated + set_status(on, switch=1, nowait) # Set status of switch to 'on' or 'off' (bool) + set_value(index, value, nowait) # Set int value of any index. + heartbeat(nowait) # Send heartbeat to device + updatedps(index=[1], nowait) # Send updatedps command to device + turn_on(switch=1, nowait) # Turn on device / switch # + turn_off(switch=1, nowait) # Turn off + set_timer(num_secs, nowait) # Set timer for num_secs + set_debug(toggle, color) # Activate verbose debugging output + set_sendWait(num_secs) # Time to wait after sending commands before pulling response + detect_available_dps() # Return list of DPS available from device + generate_payload(command, data) # Generate TuyaMessage payload for command with data + send(payload) # Send payload to device (do not wait for response) + receive() + + Additional Classes + TemperatureUnit(Enum) + Enum to represent the unit of the temperature (C° or F°) + + ExtendedEnum(Enum) + Internal use only. + + InverterHeatPumpMode(ExtendedEnum) + Enum to represent the mode of the inverter. There is no documentation + about the modes, therefore only the known ones are listed. Feel free + to contribute if you know more about these modes. + + InverterHeatPumpFault(ExtendedEnum) + Enum to represent the fault of the inverter. There is no documentation + about the fault codes, therefore only the known ones are listed. Feel + free to contribute if you know more about these codes. +""" + +from enum import Enum +from ..core import Device + + +class InverterHeatPumpDevice(Device): + + DPS = "dps" + ON_DP = "1" + INLET_WATER_TEMP_DP = "102" + UNIT_DP = "103" + HEATING_CAPACITY_PERCENT_DP = "104" + MODE_DP = "105" + TARGET_WATER_TEMP_DP = "106" + LOWER_LIMIT_TARGET_WATER_TEMP_DP = "107" + UPPER_LIMIT_TARGET_WATER_TEMP_DP = "108" + FAULT_DP = "115" + FAULT2_DP = "116" + SILENCE_MODE_DP = "117" + + def is_on(self): + return self.status()[self.DPS][self.ON_DP] + + def get_unit(self): + return TemperatureUnit(self.status()[self.DPS][self.UNIT_DP]) + + def get_inlet_water_temp(self): + return self.status()[self.DPS][self.INLET_WATER_TEMP_DP] + + def get_target_water_temp(self): + return self.status()[self.DPS][self.TARGET_WATER_TEMP_DP] + + def get_lower_limit_target_water_temp(self): + return self.status()[self.DPS][self.LOWER_LIMIT_TARGET_WATER_TEMP_DP] + + def get_upper_limit_target_water_temp(self): + return self.status()[self.DPS][self.UPPER_LIMIT_TARGET_WATER_TEMP_DP] + + def get_heating_capacity_percent(self): + return self.status()[self.DPS][self.HEATING_CAPACITY_PERCENT_DP] + + def get_mode(self, raw=False): + """There is no documentation about the modes. Therefore, your device + could push unkown modes and this method will return + InverterHeatPumpMode.UNKNOWN. You can use raw=True to get pushed value. + Feel free to contribute if you get unknown modes. + """ + string_mode = self.status()[self.DPS][self.MODE_DP] + + if raw: + return string_mode + + if InverterHeatPumpMode.is_known(string_mode): + return InverterHeatPumpMode(string_mode) + + return InverterHeatPumpMode.UNKNOWN + + def get_fault(self, raw=False): + """There is no documentation about the fault codes. Therefore, your + device could push unkown fault codes and this method will return + InverterHeatPumpFault.UNKNOWN. You can use raw=True to get pushed value. + Feel free to contribute if you get unknown fault codes. + """ + fault = self.status()[self.DPS][self.FAULT_DP] + + if raw: + return fault + + if InverterHeatPumpFault.is_known(fault): + return InverterHeatPumpFault(fault) + + return InverterHeatPumpFault.UNKNOWN + + def is_silence_mode(self): + """Paradoxically, the silence mode is on when SILENCE_MODE_DP is False""" + return not self.status()[self.DPS][self.SILENCE_MODE_DP] + + def set_unit(self, unit): + self.set_value(self.UNIT_DP, unit.value) + + def set_target_water_temp(self, target_water_temp): + sts = self.status()[self.DPS] + lower_limit, upper_limit = ( + sts[self.LOWER_LIMIT_TARGET_WATER_TEMP_DP], + sts[self.UPPER_LIMIT_TARGET_WATER_TEMP_DP], + ) + if lower_limit <= target_water_temp <= upper_limit: + self.set_value(self.TARGET_WATER_TEMP_DP, target_water_temp) + else: + raise ValueError("Target water temperature must be between {} and {}".format(lower_limit, upper_limit)) + + def set_silence_mode(self, silence_mode): + """Paradoxically, the silence mode is on when SILENCE_MODE_DP is False""" + self.set_value(self.SILENCE_MODE_DP, not silence_mode) + +class TemperatureUnit(Enum): + CELSIUS = True + FAHRENHEIT = False + +class ExtendedEnum(Enum): + @classmethod + def is_known(self, value): + return value in self._value2member_map_ + +class InverterHeatPumpMode(ExtendedEnum): + UNKNOWN = "unknown" + HEATING = "warm" + + +class InverterHeatPumpFault(ExtendedEnum): + UNKNOWN = -1 + NOMINAL = 0 + NO_WATER_FLOW = 4 diff --git a/tinytuya/Contrib/README.md b/tinytuya/Contrib/README.md index 49efec6a..9ee2f047 100644 --- a/tinytuya/Contrib/README.md +++ b/tinytuya/Contrib/README.md @@ -98,6 +98,29 @@ In addition to the built-in `OutletDevice`, `BulbDevice` and `CoverDevice` devic d.set_timer(2) ``` +### InverterHeatPumpDevice + +* InverterHeatPumpDevice - A community-contributed Python module to add support for Tuya WiFi smart inverter heat pump +* Author: [Valentin Dusollier](https://github.com/valentindusollier) +* Tested: Fairland Inverter+ 21kW (IPHR55) + + ```python + from tinytuya import Contrib + + device = Contrib.InverterHeatPumpDevice(dev_id="devid", address="ip", local_key="key", version="3.3") + + device.set_unit(Contrib.TemperatureUnit.CELSIUS) + + if device.get_fault() != Contrib.InverterHeatPumpFault.NOMINAL: + print("The inverter can't work normally. Turning off...") + device.turn_off() + exit() + + if device.get_inlet_water_temp() < 26: + device.set_silence_mode(True) + device.set_target_water_temp(28) + ``` + ## Submit Your Device * We welcome new device modules! diff --git a/tinytuya/Contrib/__init__.py b/tinytuya/Contrib/__init__.py index ffb9de61..dfbc6e75 100644 --- a/tinytuya/Contrib/__init__.py +++ b/tinytuya/Contrib/__init__.py @@ -5,5 +5,6 @@ from .DoorbellDevice import DoorbellDevice from .ClimateDevice import ClimateDevice from .AtorchTemperatureControllerDevice import AtorchTemperatureControllerDevice +from .InverterHeatPumpDevice import InverterHeatPumpDevice, TemperatureUnit, InverterHeatPumpMode, InverterHeatPumpFault -DeviceTypes = ["ThermostatDevice", "IRRemoteControlDevice", "SocketDevice", "DoorbellDevice", "ClimateDevice", "AtorchTemperatureControllerDevice"] +DeviceTypes = ["ThermostatDevice", "IRRemoteControlDevice", "SocketDevice", "DoorbellDevice", "ClimateDevice", "AtorchTemperatureControllerDevice", "InverterHeatPumpDevice"] From b93eb9ca0ebbda3d3ffa855c8f8082f5a8434c4a Mon Sep 17 00:00:00 2001 From: Valentin Dusollier Date: Sun, 18 Jun 2023 14:37:29 +0200 Subject: [PATCH 2/4] Add python-dotenv in requirements.txt --- requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements.txt b/requirements.txt index 97b7b7db..6b2584bd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,3 +2,4 @@ pycryptodome # Encryption - AES can also be provided via PyCrypto or pyaes requests # Used for Setup Wizard - Tuya IoT Platform calls colorama # Makes ANSI escape character sequences work under MS Windows. +python-dotenv # Reads .env file for environment variables From e7c8c93793327f42400847d4c9247958828c8e71 Mon Sep 17 00:00:00 2001 From: Valentin Dusollier Date: Sun, 18 Jun 2023 15:01:26 +0200 Subject: [PATCH 3/4] Make the dotenv module optional --- testcontrib.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/testcontrib.py b/testcontrib.py index 31411955..381affaa 100644 --- a/testcontrib.py +++ b/testcontrib.py @@ -19,9 +19,13 @@ import time import os -from dotenv import load_dotenv -load_dotenv() +# Load environment variables from .env file +try: + from dotenv import load_dotenv + load_dotenv() +except ModuleNotFoundError: + pass # dotenv not installed, ignore IHP_DEVICEID = os.getenv("IHP_DEVICEID", None) IHP_DEVICEIP = os.getenv("IHP_DEVICEIP", None) From 6348ab2ca36db53781b3baece3c0f7593313124c Mon Sep 17 00:00:00 2001 From: Valentin Dusollier Date: Sun, 18 Jun 2023 15:35:15 +0200 Subject: [PATCH 4/4] Remove python-dotenv from requirements.txt --- requirements.txt | 1 - 1 file changed, 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 6b2584bd..97b7b7db 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,4 +2,3 @@ pycryptodome # Encryption - AES can also be provided via PyCrypto or pyaes requests # Used for Setup Wizard - Tuya IoT Platform calls colorama # Makes ANSI escape character sequences work under MS Windows. -python-dotenv # Reads .env file for environment variables