Ver Fonte

Merge pull request #21 from nikrolls/feature/refactor-for-multidevice-support

Refactor to prepare for supporting multiple device types
Nik Rolls há 6 anos atrás
pai
commit
346e6cbf98

+ 2 - 2
.gitignore

@@ -1,2 +1,2 @@
-/.idea/
-__pycache__/
+/.idea/
+__pycache__/

+ 8 - 4
README.md

@@ -34,8 +34,11 @@ Work is in progress to support Goldair WiFi dehumidifiers.
 
 Installation
 ------------
-The preferred installation method is via [Custom Updater](https://github.com/custom-components/custom_updater). Once 
-you have Custom Updater set up, simply go to the dev-service page 
+The preferred installation method is via [HACS](https://hacs.xyz/). Once you have HACS set up, simply follow the
+[instructions for adding a custom repository](https://hacs.xyz/docs/navigation/settings#custom-repositories).
+
+You can also use [Custom Updater](https://github.com/custom-components/custom_updater). Once you have Custom Updater set
+up, simply go to the dev-service page
 <img src="https://www.home-assistant.io/images/screenshots/developer-tool-services-icon.png" alt="The dev-service icon" width="30">
 and call the `custom_updater.install` service with this service data:
 ```json
@@ -77,8 +80,9 @@ goldair_climate:
 
 #### type
 &nbsp;&nbsp;&nbsp;&nbsp;*(string) (Required)* The type of Goldair device. Currently `heater` is the only option; a 
-                                              future update will add support for dehumidifiers, so setting the type now 
-                                              will prevent the component breaking when this functionality is released.
+                                              future update will add support for dehumidifiers and other devices, so
+                                              setting the type now will prevent the component breaking when this
+                                              functionality is released.
 
 #### climate
 &nbsp;&nbsp;&nbsp;&nbsp;*(boolean) (Optional)* Whether to surface this heater as a climate device.

+ 224 - 468
custom_components/goldair_climate/__init__.py

@@ -1,468 +1,224 @@
-"""
-Platform for Goldair WiFi-connected heaters and panels.
-
-Based on sean6541/tuya-homeassistant for service call logic, and TarxBoy's
-investigation into Goldair's tuyapi statuses
-https://github.com/codetheweb/tuyapi/issues/31.
-"""
-from time import time
-from threading import Timer, Lock
-import logging
-import json
-import voluptuous as vol
-
-import homeassistant.helpers.config_validation as cv
-from homeassistant.const import (CONF_NAME, CONF_HOST, ATTR_TEMPERATURE, TEMP_CELSIUS)
-from homeassistant.components.climate import ATTR_OPERATION_MODE
-from homeassistant.helpers.discovery import load_platform
-
-VERSION = '0.0.3'
-REQUIREMENTS = ['pytuya==7.0']
-
-_LOGGER = logging.getLogger(__name__)
-
-DOMAIN = 'goldair_climate'
-DATA_GOLDAIR_CLIMATE = 'data_goldair_climate'
-
-
-CONF_DEVICE_ID = 'device_id'
-CONF_LOCAL_KEY = 'local_key'
-CONF_TYPE = 'type'
-CONF_TYPE_HEATER = 'heater'
-CONF_CLIMATE = 'climate'
-CONF_SENSOR = 'sensor'
-CONF_CHILD_LOCK = 'child_lock'
-CONF_DISPLAY_LIGHT = 'display_light'
-
-ATTR_ON = 'on'
-ATTR_TARGET_TEMPERATURE = 'target_temperature'
-ATTR_CHILD_LOCK = 'child_lock'
-ATTR_FAULT = 'fault'
-ATTR_POWER_LEVEL = 'power_level'
-ATTR_TIMER_MINUTES = 'timer_minutes'
-ATTR_TIMER_ON = 'timer_on'
-ATTR_DISPLAY_ON = 'display_on'
-ATTR_POWER_MODE = 'power_mode'
-ATTR_ECO_TARGET_TEMPERATURE = 'eco_' + ATTR_TARGET_TEMPERATURE
-
-STATE_COMFORT = 'Comfort'
-STATE_ECO = 'Eco'
-STATE_ANTI_FREEZE = 'Anti-freeze'
-
-GOLDAIR_PROPERTY_TO_DPS_ID = {
-    ATTR_ON: '1',
-    ATTR_TARGET_TEMPERATURE: '2',
-    ATTR_TEMPERATURE: '3',
-    ATTR_OPERATION_MODE: '4',
-    ATTR_CHILD_LOCK: '6',
-    ATTR_FAULT: '12',
-    ATTR_POWER_LEVEL: '101',
-    ATTR_TIMER_MINUTES: '102',
-    ATTR_TIMER_ON: '103',
-    ATTR_DISPLAY_ON: '104',
-    ATTR_POWER_MODE: '105',
-    ATTR_ECO_TARGET_TEMPERATURE: '106'
-}
-
-GOLDAIR_MODE_TO_DPS_MODE = {
-    STATE_COMFORT: 'C',
-    STATE_ECO: 'ECO',
-    STATE_ANTI_FREEZE: 'AF'
-}
-GOLDAIR_POWER_LEVEL_TO_DPS_LEVEL = {
-    'Stop': 'stop',
-    '1': '1',
-    '2': '2',
-    '3': '3',
-    '4': '4',
-    '5': '5',
-    'Auto': 'auto'
-}
-GOLDAIR_POWER_MODES = ['auto', 'user']
-
-PLATFORM_SCHEMA = vol.Schema({
-    vol.Required(CONF_NAME): cv.string,
-    vol.Required(CONF_HOST): cv.string,
-    vol.Required(CONF_DEVICE_ID): cv.string,
-    vol.Required(CONF_LOCAL_KEY): cv.string,
-    vol.Required(CONF_TYPE): vol.In([CONF_TYPE_HEATER]),
-    vol.Optional(CONF_CLIMATE, default=True): cv.boolean,
-    vol.Optional(CONF_SENSOR, default=False): cv.boolean,
-    vol.Optional(CONF_DISPLAY_LIGHT, default=False): cv.boolean,
-    vol.Optional(CONF_CHILD_LOCK, default=False): cv.boolean
-})
-
-CONFIG_SCHEMA = vol.Schema({
-    DOMAIN: vol.All(cv.ensure_list, [PLATFORM_SCHEMA])
-}, extra=vol.ALLOW_EXTRA)
-
-
-def setup(hass, config):
-    hass.data[DOMAIN] = {}
-    for device_config in config.get(DOMAIN, []):
-        host = device_config.get(CONF_HOST)
-
-        device = GoldairHeaterDevice(
-            device_config.get(CONF_NAME),
-            device_config.get(CONF_DEVICE_ID),
-            device_config.get(CONF_HOST),
-            device_config.get(CONF_LOCAL_KEY)
-        )
-        hass.data[DOMAIN][host] = device
-
-        if device_config.get(CONF_TYPE) == CONF_TYPE_HEATER:
-            discovery_info = {'host': host, 'type': 'heater'}
-            if device_config.get(CONF_CLIMATE):
-                load_platform(hass, 'climate', DOMAIN, discovery_info, config)
-            if device_config.get(CONF_SENSOR):
-                load_platform(hass, 'sensor', DOMAIN, discovery_info, config)
-            if device_config.get(CONF_DISPLAY_LIGHT):
-                load_platform(hass, 'light', DOMAIN, discovery_info, config)
-            if device_config.get(CONF_CHILD_LOCK):
-                load_platform(hass, 'lock', DOMAIN, discovery_info, config)
-
-    return True
-
-
-class GoldairHeaterDevice(object):
-    def __init__(self, name, dev_id, address, local_key):
-        """
-        Represents a Goldair Heater device.
-
-        Args:
-            dev_id (str): The device id.
-            address (str): The network address.
-            local_key (str): The encryption key.
-        """
-        import pytuya
-        self._name = name
-        self._api = pytuya.Device(dev_id, address, local_key, 'device')
-
-        self._fixed_properties = {}
-        self._reset_cached_state()
-
-        self._TEMPERATURE_UNIT = TEMP_CELSIUS
-        self._TEMPERATURE_STEP = 1
-        self._TEMPERATURE_LIMITS = {
-            STATE_COMFORT: {
-                'min': 5,
-                'max': 35
-            },
-            STATE_ECO: {
-                'min': 5,
-                'max': 21
-            }
-        }
-
-        # API calls to update Goldair heaters are asynchronous and non-blocking. This means
-        # you can send a change and immediately request an updated state (like HA does),
-        # but because it has not yet finished processing you will be returned the old state.
-        # The solution is to keep a temporary list of changed properties that we can overlay
-        # onto the state while we wait for the board to update its switches.
-        self._FAKE_IT_TIL_YOU_MAKE_IT_TIMEOUT = 10
-        self._CACHE_TIMEOUT = 20
-        self._CONNECTION_ATTEMPTS = 2
-        self._lock = Lock()
-
-    @property
-    def name(self):
-        return self._name
-
-    @property
-    def is_on(self):
-        return self._get_cached_state()[ATTR_ON]
-
-    def turn_on(self):
-        self._set_properties({ATTR_ON: True})
-
-    def turn_off(self):
-        self._set_properties({ATTR_ON: False})
-
-    @property
-    def temperature_unit(self):
-        return self._TEMPERATURE_UNIT
-
-    @property
-    def target_temperature(self):
-        state = self._get_cached_state()
-        if self.operation_mode == STATE_COMFORT:
-            return state[ATTR_TARGET_TEMPERATURE]
-        elif self.operation_mode == STATE_ECO:
-            return state[ATTR_ECO_TARGET_TEMPERATURE]
-        else:
-            return None
-
-    @property
-    def target_temperature_step(self):
-        return self._TEMPERATURE_STEP
-
-    @property
-    def min_target_teperature(self):
-        if self.operation_mode and self.operation_mode != STATE_ANTI_FREEZE:
-            return self._TEMPERATURE_LIMITS[self.operation_mode]['min']
-        else:
-            return None
-
-    @property
-    def max_target_temperature(self):
-        if self.operation_mode and self.operation_mode != STATE_ANTI_FREEZE:
-            return self._TEMPERATURE_LIMITS[self.operation_mode]['max']
-        else:
-            return None
-
-    def set_target_temperature(self, target_temperature):
-        target_temperature = int(round(target_temperature))
-        operation_mode = self.operation_mode
-
-        if operation_mode == STATE_ANTI_FREEZE:
-            raise ValueError('You cannot set the temperature in Anti-freeze mode.')
-
-        limits = self._TEMPERATURE_LIMITS[operation_mode]
-        if not limits['min'] <= target_temperature <= limits['max']:
-            raise ValueError(
-                f'Target temperature ({target_temperature}) must be between '
-                f'{limits["min"]} and {limits["max"]}'
-            )
-
-        if operation_mode == STATE_COMFORT:
-            self._set_properties({ATTR_TARGET_TEMPERATURE: target_temperature})
-        elif operation_mode == STATE_ECO:
-            self._set_properties({ATTR_ECO_TARGET_TEMPERATURE: target_temperature})
-
-    @property
-    def current_temperature(self):
-        return self._get_cached_state()[ATTR_TEMPERATURE]
-
-    @property
-    def operation_mode(self):
-        return self._get_cached_state()[ATTR_OPERATION_MODE]
-
-    @property
-    def operation_mode_list(self):
-        return list(GOLDAIR_MODE_TO_DPS_MODE.keys())
-
-    def set_operation_mode(self, new_mode):
-        if new_mode not in GOLDAIR_MODE_TO_DPS_MODE:
-            raise ValueError(f'Invalid mode: {new_mode}')
-        self._set_properties({ATTR_OPERATION_MODE: new_mode})
-
-    @property
-    def is_child_locked(self):
-        return self._get_cached_state()[ATTR_CHILD_LOCK]
-
-    def enable_child_lock(self):
-        self._set_properties({ATTR_CHILD_LOCK: True})
-
-    def disable_child_lock(self):
-        self._set_properties({ATTR_CHILD_LOCK: False})
-
-    @property
-    def is_faulted(self):
-        return self._get_cached_state()[ATTR_FAULT]
-
-    @property
-    def power_level(self):
-        power_mode = self._get_cached_state()[ATTR_POWER_MODE]
-        if power_mode == 'user':
-            return self._get_cached_state()[ATTR_POWER_LEVEL]
-        elif power_mode == 'auto':
-            return 'Auto'
-        else:
-            return None
-
-    @property
-    def power_level_list(self):
-        return list(GOLDAIR_POWER_LEVEL_TO_DPS_LEVEL.keys())
-
-    def set_power_level(self, new_level):
-        if new_level not in GOLDAIR_POWER_LEVEL_TO_DPS_LEVEL.keys():
-            raise ValueError(f'Invalid power level: {new_level}')
-        self._set_properties({ATTR_POWER_LEVEL: new_level})
-
-    @property
-    def timer_timeout_in_minutes(self):
-        return self._get_cached_state()[ATTR_TIMER_MINUTES]
-
-    @property
-    def is_timer_on(self):
-        return self._get_cached_state()[ATTR_TIMER_ON]
-
-    def start_timer(self, minutes):
-        self._set_properties({
-            ATTR_TIMER_ON: True,
-            ATTR_TIMER_MINUTES: minutes
-        })
-
-    def stop_timer(self):
-        self._set_properties({ATTR_TIMER_ON: False})
-
-    @property
-    def is_display_on(self):
-        return self._get_cached_state()[ATTR_DISPLAY_ON]
-
-    def turn_display_on(self):
-        self._set_properties({ATTR_DISPLAY_ON: True})
-
-    def turn_display_off(self):
-        self._set_properties({ATTR_DISPLAY_ON: False})
-
-    @property
-    def power_mode(self):
-        return self._get_cached_state()[ATTR_POWER_MODE]
-
-    def set_power_mode(self, new_mode):
-        if new_mode not in GOLDAIR_POWER_MODES:
-            raise ValueError(f'Invalid user mode: {new_mode}')
-        self._set_properties({ATTR_POWER_MODE: new_mode})
-
-    @property
-    def eco_target_temperature(self):
-        return self._get_cached_state()[ATTR_ECO_TARGET_TEMPERATURE]
-
-    def set_eco_target_temperature(self, eco_target_temperature):
-        self._set_properties({ATTR_ECO_TARGET_TEMPERATURE: eco_target_temperature})
-
-    def set_fixed_properties(self, fixed_properties):
-        self._fixed_properties = fixed_properties
-        set_fixed_properties = Timer(10, lambda: self._set_properties(self._fixed_properties))
-        set_fixed_properties.start()
-
-    def refresh(self):
-        now = time()
-        cached_state = self._get_cached_state()
-        if now - cached_state['updated_at'] >= self._CACHE_TIMEOUT:
-            self._retry_on_failed_connection(lambda: self._refresh_cached_state(), 'Failed to refresh device state.')
-
-    def _reset_cached_state(self):
-        self._cached_state = {
-            ATTR_ON: None,
-            ATTR_TARGET_TEMPERATURE: None,
-            ATTR_TEMPERATURE: None,
-            ATTR_OPERATION_MODE: None,
-            ATTR_CHILD_LOCK: None,
-            ATTR_FAULT: None,
-            ATTR_POWER_LEVEL: None,
-            ATTR_TIMER_MINUTES: None,
-            ATTR_TIMER_ON: None,
-            ATTR_DISPLAY_ON: None,
-            ATTR_POWER_MODE: None,
-            ATTR_ECO_TARGET_TEMPERATURE: None,
-            'updated_at': 0
-        }
-        self._pending_updates = {}
-
-    def _refresh_cached_state(self):
-        new_state = self._api.status()
-        self._update_cached_state_from_dps(new_state['dps'])
-        _LOGGER.info(f'refreshed device state: {json.dumps(new_state)}')
-        _LOGGER.debug(f'new cache state: {json.dumps(self._cached_state)}')
-        _LOGGER.debug(f'new cache state (including pending properties): {json.dumps(self._get_cached_state())}')
-
-    def _set_properties(self, properties):
-        if len(properties) == 0:
-            return
-
-        self._add_properties_to_pending_updates(properties)
-        self._debounce_sending_updates()
-
-    def _add_properties_to_pending_updates(self, properties):
-        now = time()
-        properties = {**properties, **self._fixed_properties}
-
-        pending_updates = self._get_pending_updates()
-        for key, value in properties.items():
-            pending_updates[key] = {
-                'value': value,
-                'updated_at': now
-            }
-
-        _LOGGER.debug(f'new pending updates: {json.dumps(self._pending_updates)}')
-
-    def _debounce_sending_updates(self):
-        try:
-            self._debounce.cancel()
-        except AttributeError:
-            pass
-        self._debounce = Timer(1, self._send_pending_updates)
-        self._debounce.start()
-
-    def _send_pending_updates(self):
-        pending_properties = self._get_pending_properties()
-        new_state = GoldairHeaterDevice._generate_dps_payload_for_properties(pending_properties)
-        payload = self._api.generate_payload('set', new_state)
-
-        _LOGGER.debug(f'sending updated properties: {json.dumps(pending_properties)}')
-        _LOGGER.info(f'sending dps update: {json.dumps(new_state)}')
-
-        self._retry_on_failed_connection(lambda: self._send_payload(payload), 'Failed to update device state.')
-
-    def _send_payload(self, payload):
-        try:
-            self._lock.acquire()
-            self._api._send_receive(payload)
-            self._cached_state['updated_at'] = 0
-            now = time()
-            pending_updates = self._get_pending_updates()
-            for key, value in pending_updates.items():
-                pending_updates[key]['updated_at'] = now
-        finally:
-            self._lock.release()
-
-    def _retry_on_failed_connection(self, func, error_message):
-        for i in range(self._CONNECTION_ATTEMPTS):
-            try:
-                func()
-            except:
-                if i + 1 == self._CONNECTION_ATTEMPTS:
-                    self._reset_cached_state()
-                    _LOGGER.error(error_message)
-
-    def _get_cached_state(self):
-        cached_state = self._cached_state.copy()
-        _LOGGER.debug(f'pending updates: {json.dumps(self._get_pending_updates())}')
-        return {**cached_state, **self._get_pending_properties()}
-
-    def _get_pending_properties(self):
-        return {key: info['value'] for key, info in self._get_pending_updates().items()}
-
-    def _get_pending_updates(self):
-        now = time()
-        self._pending_updates = {key: value for key, value in self._pending_updates.items()
-                                 if now - value['updated_at'] < self._FAKE_IT_TIL_YOU_MAKE_IT_TIMEOUT}
-        return self._pending_updates
-
-    def _update_cached_state_from_dps(self, dps):
-        now = time()
-
-        for key, dps_id in GOLDAIR_PROPERTY_TO_DPS_ID.items():
-            if dps_id in dps:
-                value = dps[dps_id]
-                if dps_id == GOLDAIR_PROPERTY_TO_DPS_ID[ATTR_OPERATION_MODE]:
-                    self._cached_state[key] = GoldairHeaterDevice._get_key_for_value(GOLDAIR_MODE_TO_DPS_MODE, value)
-                elif dps_id == GOLDAIR_PROPERTY_TO_DPS_ID[ATTR_POWER_LEVEL]:
-                    self._cached_state[key] = GoldairHeaterDevice._get_key_for_value(GOLDAIR_POWER_LEVEL_TO_DPS_LEVEL, value)
-                else:
-                    self._cached_state[key] = value
-                self._cached_state['updated_at'] = now
-
-    @staticmethod
-    def _generate_dps_payload_for_properties(properties):
-        dps = {}
-
-        for key, dps_id in GOLDAIR_PROPERTY_TO_DPS_ID.items():
-            if key in properties:
-                value = properties[key]
-                if dps_id == GOLDAIR_PROPERTY_TO_DPS_ID[ATTR_OPERATION_MODE]:
-                    dps[dps_id] = GOLDAIR_MODE_TO_DPS_MODE[value]
-                elif dps_id == GOLDAIR_PROPERTY_TO_DPS_ID[ATTR_POWER_LEVEL]:
-                    dps[dps_id] = GOLDAIR_POWER_LEVEL_TO_DPS_LEVEL[value]
-                else:
-                    dps[dps_id] = value
-
-        return dps
-
-    @staticmethod
-    def _get_key_for_value(obj, value):
-        keys = list(obj.keys())
-        values = list(obj.values())
-        return keys[values.index(value)]
+"""
+Platform for Goldair WiFi-connected heaters and panels.
+
+Based on sean6541/tuya-homeassistant for service call logic, and TarxBoy's
+investigation into Goldair's tuyapi statuses
+https://github.com/codetheweb/tuyapi/issues/31.
+"""
+from time import time
+from threading import Timer, Lock
+import logging
+import json
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.const import (CONF_NAME, CONF_HOST, TEMP_CELSIUS)
+from homeassistant.helpers.discovery import load_platform
+
+VERSION = '0.0.3'
+
+_LOGGER = logging.getLogger(__name__)
+
+DOMAIN = 'goldair_climate'
+DATA_GOLDAIR_CLIMATE = 'data_goldair_climate'
+
+CONF_DEVICE_ID = 'device_id'
+CONF_LOCAL_KEY = 'local_key'
+CONF_TYPE = 'type'
+CONF_TYPE_HEATER = 'heater'
+CONF_CLIMATE = 'climate'
+CONF_SENSOR = 'sensor'
+CONF_DISPLAY_LIGHT = 'display_light'
+CONF_CHILD_LOCK = 'child_lock'
+
+PLATFORM_SCHEMA = vol.Schema({
+    vol.Required(CONF_NAME): cv.string,
+    vol.Required(CONF_HOST): cv.string,
+    vol.Required(CONF_DEVICE_ID): cv.string,
+    vol.Required(CONF_LOCAL_KEY): cv.string,
+    vol.Required(CONF_TYPE): vol.In([CONF_TYPE_HEATER]),
+    vol.Optional(CONF_CLIMATE, default=True): cv.boolean,
+    vol.Optional(CONF_SENSOR, default=False): cv.boolean,
+    vol.Optional(CONF_DISPLAY_LIGHT, default=False): cv.boolean,
+    vol.Optional(CONF_CHILD_LOCK, default=False): cv.boolean,
+})
+
+CONFIG_SCHEMA = vol.Schema({
+    DOMAIN: vol.All(cv.ensure_list, [PLATFORM_SCHEMA])
+}, extra=vol.ALLOW_EXTRA)
+
+
+def setup(hass, config):
+    hass.data[DOMAIN] = {}
+    for device_config in config.get(DOMAIN, []):
+        host = device_config.get(CONF_HOST)
+
+        device = GoldairTuyaDevice(
+            device_config.get(CONF_NAME),
+            device_config.get(CONF_DEVICE_ID),
+            device_config.get(CONF_HOST),
+            device_config.get(CONF_LOCAL_KEY)
+        )
+        hass.data[DOMAIN][host] = device
+        discovery_info = {CONF_HOST: host, CONF_TYPE: device_config.get(CONF_TYPE)}
+
+        if device_config.get(CONF_CLIMATE) == True:
+            load_platform(hass, 'climate', DOMAIN, discovery_info, config)
+        if device_config.get(CONF_SENSOR) == True:
+            load_platform(hass, 'sensor', DOMAIN, discovery_info, config)
+        if device_config.get(CONF_DISPLAY_LIGHT) == True:
+            load_platform(hass, 'light', DOMAIN, discovery_info, config)
+        if device_config.get(CONF_CHILD_LOCK) == True:
+            load_platform(hass, 'lock', DOMAIN, discovery_info, config)
+
+    return True
+
+
+class GoldairTuyaDevice(object):
+    def __init__(self, name, dev_id, address, local_key):
+        """
+        Represents a Goldair Tuya-based device.
+
+        Args:
+            dev_id (str): The device id.
+            address (str): The network address.
+            local_key (str): The encryption key.
+        """
+        import pytuya
+        self._name = name
+        self._api = pytuya.Device(dev_id, address, local_key, 'device')
+
+        self._fixed_properties = {}
+        self._reset_cached_state()
+
+        self._TEMPERATURE_UNIT = TEMP_CELSIUS
+
+        # API calls to update Goldair heaters are asynchronous and non-blocking. This means
+        # you can send a change and immediately request an updated state (like HA does),
+        # but because it has not yet finished processing you will be returned the old state.
+        # The solution is to keep a temporary list of changed properties that we can overlay
+        # onto the state while we wait for the board to update its switches.
+        self._FAKE_IT_TIL_YOU_MAKE_IT_TIMEOUT = 10
+        self._CACHE_TIMEOUT = 20
+        self._CONNECTION_ATTEMPTS = 2
+        self._lock = Lock()
+
+    @property
+    def name(self):
+        return self._name
+
+    @property
+    def temperature_unit(self):
+        return self._TEMPERATURE_UNIT
+
+    def set_fixed_properties(self, fixed_properties):
+        self._fixed_properties = fixed_properties
+        set_fixed_properties = Timer(10, lambda: self._set_properties(self._fixed_properties))
+        set_fixed_properties.start()
+
+    def refresh(self):
+        now = time()
+        cached_state = self._get_cached_state()
+        if now - cached_state['updated_at'] >= self._CACHE_TIMEOUT:
+            self._cached_state['updated_at'] = time()
+            self._retry_on_failed_connection(lambda: self._refresh_cached_state(), 'Failed to refresh device state.')
+
+    def get_property(self, dps_id):
+        cached_state = self._get_cached_state()
+        if dps_id in cached_state:
+            return cached_state[dps_id]
+        else:
+            return None
+
+    def set_property(self, dps_id, value):
+        self._set_properties({dps_id: value})
+
+    def _reset_cached_state(self):
+        self._cached_state = {
+            'updated_at': 0
+        }
+        self._pending_updates = {}
+
+    def _refresh_cached_state(self):
+        new_state = self._api.status()
+        self._cached_state = new_state['dps']
+        self._cached_state['updated_at'] = time()
+        _LOGGER.info(f'refreshed device state: {json.dumps(new_state)}')
+        _LOGGER.debug(f'new cache state (including pending properties): {json.dumps(self._get_cached_state())}')
+
+    def _set_properties(self, properties):
+        if len(properties) == 0:
+            return
+
+        self._add_properties_to_pending_updates(properties)
+        self._debounce_sending_updates()
+
+    def _add_properties_to_pending_updates(self, properties):
+        now = time()
+        properties = {**properties, **self._fixed_properties}
+
+        pending_updates = self._get_pending_updates()
+        for key, value in properties.items():
+            pending_updates[key] = {
+                'value': value,
+                'updated_at': now
+            }
+
+        _LOGGER.debug(f'new pending updates: {json.dumps(self._pending_updates)}')
+
+    def _debounce_sending_updates(self):
+        try:
+            self._debounce.cancel()
+        except AttributeError:
+            pass
+        self._debounce = Timer(1, self._send_pending_updates)
+        self._debounce.start()
+
+    def _send_pending_updates(self):
+        pending_properties = self._get_pending_properties()
+        payload = self._api.generate_payload('set', pending_properties)
+
+        _LOGGER.info(f'sending dps update: {json.dumps(pending_properties)}')
+
+        self._retry_on_failed_connection(lambda: self._send_payload(payload), 'Failed to update device state.')
+
+    def _send_payload(self, payload):
+        try:
+            self._lock.acquire()
+            self._api._send_receive(payload)
+            self._cached_state['updated_at'] = 0
+            now = time()
+            pending_updates = self._get_pending_updates()
+            for key, value in pending_updates.items():
+                pending_updates[key]['updated_at'] = now
+        finally:
+            self._lock.release()
+
+    def _retry_on_failed_connection(self, func, error_message):
+        for i in range(self._CONNECTION_ATTEMPTS):
+            try:
+                func()
+            except:
+                if i + 1 == self._CONNECTION_ATTEMPTS:
+                    self._reset_cached_state()
+                    _LOGGER.error(error_message)
+
+    def _get_cached_state(self):
+        cached_state = self._cached_state.copy()
+        _LOGGER.debug(f'pending updates: {json.dumps(self._get_pending_updates())}')
+        return {**cached_state, **self._get_pending_properties()}
+
+    def _get_pending_properties(self):
+        return {key: info['value'] for key, info in self._get_pending_updates().items()}
+
+    def _get_pending_updates(self):
+        now = time()
+        self._pending_updates = {key: value for key, value in self._pending_updates.items()
+                                 if now - value['updated_at'] < self._FAKE_IT_TIL_YOU_MAKE_IT_TIMEOUT}
+        return self._pending_updates
+
+    @staticmethod
+    def get_key_for_value(obj, value):
+        keys = list(obj.keys())
+        values = list(obj.values())
+        return keys[values.index(value)]

+ 14 - 136
custom_components/goldair_climate/climate.py

@@ -1,136 +1,14 @@
-"""
-Platform to control Goldair WiFi-connected heaters and panels.
-"""
-from homeassistant.components.climate import (
-    ClimateDevice, ATTR_OPERATION_MODE, ATTR_TEMPERATURE
-)
-from homeassistant.components.climate.const import (
-    SUPPORT_ON_OFF, SUPPORT_TARGET_TEMPERATURE, SUPPORT_OPERATION_MODE, SUPPORT_SWING_MODE
-)
-from homeassistant.const import STATE_UNAVAILABLE
-import custom_components.goldair_climate as goldair_climate
-
-SUPPORT_FLAGS = SUPPORT_ON_OFF | SUPPORT_TARGET_TEMPERATURE | SUPPORT_OPERATION_MODE | SUPPORT_SWING_MODE
-
-def setup_platform(hass, config, add_devices, discovery_info=None):
-    """Set up the Goldair WiFi heater."""
-    device = hass.data[goldair_climate.DOMAIN][discovery_info['host']]
-    if discovery_info[goldair_climate.CONF_TYPE] == goldair_climate.CONF_TYPE_HEATER:
-        add_devices([GoldairHeater(device)])
-
-
-class GoldairHeater(ClimateDevice):
-    """Representation of a Goldair WiFi heater."""
-
-    def __init__(self, device):
-        """Initialize the heater.
-        Args:
-            name (str): The device's name.
-            device (GoldairHeaterDevice): The device API instance."""
-        self._device = device
-
-        self._support_flags = SUPPORT_FLAGS
-
-    @property
-    def supported_features(self):
-        """Return the list of supported features."""
-        return self._support_flags
-
-    @property
-    def should_poll(self):
-        """Return the polling state."""
-        return True
-
-    @property
-    def name(self):
-        """Return the name of the climate device."""
-        return self._device.name
-
-    @property
-    def state(self):
-        """Return the state of the climate device."""
-        if self._device.is_on is None:
-            return STATE_UNAVAILABLE
-        else:
-            return super().state
-
-    @property
-    def is_on(self):
-        """Return true if the device is on."""
-        return self._device.is_on
-
-    def turn_on(self):
-        """Turn on."""
-        self._device.turn_on()
-
-    def turn_off(self):
-        """Turn off."""
-        self._device.turn_off()
-
-    @property
-    def temperature_unit(self):
-        """Return the unit of measurement."""
-        return self._device.temperature_unit
-
-    @property
-    def target_temperature(self):
-        """Return the temperature we try to reach."""
-        return self._device.target_temperature
-
-    @property
-    def target_temperature_step(self):
-        """Return the supported step of target temperature."""
-        return self._device.target_temperature_step
-
-    @property
-    def min_temp(self):
-        """Return the minimum temperature."""
-        return self._device.min_target_teperature
-
-    @property
-    def max_temp(self):
-        """Return the maximum temperature."""
-        return self._device.max_target_temperature
-
-    def set_temperature(self, **kwargs):
-        """Set new target temperatures."""
-        if kwargs.get(ATTR_TEMPERATURE) is not None:
-            self._device.set_target_temperature(kwargs.get(ATTR_TEMPERATURE))
-        if kwargs.get(ATTR_OPERATION_MODE) is not None:
-            self._device.set_operation_mode(kwargs.get(ATTR_OPERATION_MODE))
-
-    @property
-    def current_temperature(self):
-        """Return the current temperature."""
-        return self._device.current_temperature
-
-    @property
-    def current_operation(self):
-        """Return current operation, ie Comfort, Eco, Anti-freeze."""
-        return self._device.operation_mode
-
-    @property
-    def operation_list(self):
-        """Return the list of available operation modes."""
-        return self._device.operation_mode_list
-
-    def set_operation_mode(self, operation_mode):
-        """Set new operation mode."""
-        self._device.set_operation_mode(operation_mode)
-
-    @property
-    def current_swing_mode(self):
-        """Return the fan setting."""
-        return self._device.power_level
-
-    @property
-    def swing_list(self):
-        """List of available swing modes."""
-        return self._device.power_level_list
-
-    def set_swing_mode(self, swing_mode):
-        """Set new target temperature."""
-        self._device.set_power_level(swing_mode)
-
-    def update(self):
-        self._device.refresh()
+"""
+Setup for different kinds of Goldair climate devices
+"""
+from homeassistant.const import CONF_HOST
+from custom_components.goldair_climate import (
+    DOMAIN, CONF_TYPE, CONF_TYPE_HEATER
+)
+from custom_components.goldair_climate.heater.climate import GoldairHeater
+
+def setup_platform(hass, config, add_devices, discovery_info=None):
+    """Set up the Goldair climate device according to its type."""
+    device = hass.data[DOMAIN][discovery_info[CONF_HOST]]
+    if discovery_info[CONF_TYPE] == CONF_TYPE_HEATER:
+        add_devices([GoldairHeater(device)])

+ 0 - 0
custom_components/goldair_climate/heater/__init__.py


+ 244 - 0
custom_components/goldair_climate/heater/climate.py

@@ -0,0 +1,244 @@
+"""
+Goldair WiFi Heater device.
+"""
+from homeassistant.const import (
+    ATTR_TEMPERATURE, TEMP_CELSIUS, STATE_UNAVAILABLE
+)
+from homeassistant.components.climate import (
+  ClimateDevice, ATTR_HVAC_MODE, ATTR_PRESET_MODE, ATTR_TEMPERATURE, HVAC_MODE_OFF, HVAC_MODE_HEAT
+)
+from homeassistant.components.climate.const import (
+    SUPPORT_TARGET_TEMPERATURE, SUPPORT_PRESET_MODE, SUPPORT_SWING_MODE
+)
+from custom_components.goldair_climate import GoldairTuyaDevice
+
+ATTR_ON = 'on'
+ATTR_TARGET_TEMPERATURE = 'target_temperature'
+ATTR_CHILD_LOCK = 'child_lock'
+ATTR_FAULT = 'fault'
+ATTR_POWER_MODE_AUTO = 'auto'
+ATTR_POWER_MODE_USER = 'user'
+ATTR_POWER_LEVEL = 'power_level'
+ATTR_TIMER_MINUTES = 'timer_minutes'
+ATTR_TIMER_ON = 'timer_on'
+ATTR_DISPLAY_ON = 'display_on'
+ATTR_POWER_MODE = 'power_mode'
+ATTR_ECO_TARGET_TEMPERATURE = 'eco_' + ATTR_TARGET_TEMPERATURE
+
+STATE_COMFORT = 'Comfort'
+STATE_ECO = 'Eco'
+STATE_ANTI_FREEZE = 'Anti-freeze'
+
+GOLDAIR_PROPERTY_TO_DPS_ID = {
+    ATTR_HVAC_MODE: '1',
+    ATTR_TARGET_TEMPERATURE: '2',
+    ATTR_TEMPERATURE: '3',
+    ATTR_PRESET_MODE: '4',
+    ATTR_CHILD_LOCK: '6',
+    ATTR_FAULT: '12',
+    ATTR_POWER_LEVEL: '101',
+    ATTR_TIMER_MINUTES: '102',
+    ATTR_TIMER_ON: '103',
+    ATTR_DISPLAY_ON: '104',
+    ATTR_POWER_MODE: '105',
+    ATTR_ECO_TARGET_TEMPERATURE: '106'
+}
+
+GOLDAIR_MODE_TO_HVAC_MODE = {
+    HVAC_MODE_OFF: False,
+    HVAC_MODE_HEAT: True
+}
+GOLDAIR_MODE_TO_PRESET_MODE = {
+    STATE_COMFORT: 'C',
+    STATE_ECO: 'ECO',
+    STATE_ANTI_FREEZE: 'AF'
+}
+GOLDAIR_POWER_LEVEL_TO_DPS_LEVEL = {
+    'Stop': 'stop',
+    '1': '1',
+    '2': '2',
+    '3': '3',
+    '4': '4',
+    '5': '5',
+    'Auto': 'auto'
+}
+GOLDAIR_POWER_MODES = [ATTR_POWER_MODE_USER, ATTR_POWER_MODE_USER]
+
+SUPPORT_FLAGS = SUPPORT_TARGET_TEMPERATURE | SUPPORT_PRESET_MODE | SUPPORT_SWING_MODE
+
+class GoldairHeater(ClimateDevice):
+    """Representation of a Goldair WiFi heater."""
+
+    def __init__(self, device):
+        """Initialize the heater.
+        Args:
+            name (str): The device's name.
+            device (GoldairTuyaDevice): The device API instance."""
+        self._device = device
+
+        self._support_flags = SUPPORT_FLAGS
+
+        self._TEMPERATURE_STEP = 1
+        self._TEMPERATURE_LIMITS = {
+            STATE_COMFORT: {
+                'min': 5,
+                'max': 35
+            },
+            STATE_ECO: {
+                'min': 5,
+                'max': 21
+            }
+        }
+
+    @property
+    def supported_features(self):
+        """Return the list of supported features."""
+        return self._support_flags
+
+    @property
+    def should_poll(self):
+        """Return the polling state."""
+        return True
+
+    @property
+    def name(self):
+        """Return the name of the climate device."""
+        return self._device.name
+
+    @property
+    def state(self):
+        """Return the state of the climate device."""
+        return self.hvac_mode
+
+    @property
+    def temperature_unit(self):
+        """Return the unit of measurement."""
+        return self._device.temperature_unit
+
+    @property
+    def target_temperature(self):
+        """Return the temperature we try to reach."""
+        if self.preset_mode == STATE_COMFORT:
+            return self._device.get_property(GOLDAIR_PROPERTY_TO_DPS_ID[ATTR_TARGET_TEMPERATURE])
+        elif self.preset_mode == STATE_ECO:
+            return self._device.get_property(GOLDAIR_PROPERTY_TO_DPS_ID[ATTR_ECO_TARGET_TEMPERATURE])
+        else:
+            return None
+
+    @property
+    def target_temperature_step(self):
+        """Return the supported step of target temperature."""
+        return self._TEMPERATURE_STEP
+
+    @property
+    def min_temp(self):
+        """Return the minimum temperature."""
+        if self.preset_mode and self.preset_mode != STATE_ANTI_FREEZE:
+            return self._TEMPERATURE_LIMITS[self.preset_mode]['min']
+        else:
+            return None
+
+    @property
+    def max_temp(self):
+        """Return the maximum temperature."""
+        if self.preset_mode and self.preset_mode != STATE_ANTI_FREEZE:
+            return self._TEMPERATURE_LIMITS[self.preset_mode]['max']
+        else:
+            return None
+
+    def set_temperature(self, **kwargs):
+        """Set new target temperatures."""
+        if kwargs.get(ATTR_PRESET_MODE) is not None:
+            self.set_preset_mode(kwargs.get(ATTR_PRESET_MODE))
+        if kwargs.get(ATTR_TEMPERATURE) is not None:
+            self.set_target_temperature(kwargs.get(ATTR_TEMPERATURE))
+
+    def set_target_temperature(self, target_temperature):
+        target_temperature = int(round(target_temperature))
+        preset_mode = self.preset_mode
+
+        if preset_mode == STATE_ANTI_FREEZE:
+            raise ValueError('You cannot set the temperature in Anti-freeze mode.')
+
+        limits = self._TEMPERATURE_LIMITS[preset_mode]
+        if not limits['min'] <= target_temperature <= limits['max']:
+            raise ValueError(
+                f'Target temperature ({target_temperature}) must be between '
+                f'{limits["min"]} and {limits["max"]}'
+            )
+
+        if preset_mode == STATE_COMFORT:
+            self._device.set_property(GOLDAIR_PROPERTY_TO_DPS_ID[ATTR_TARGET_TEMPERATURE], target_temperature)
+        elif preset_mode == STATE_ECO:
+            self._device.set_property(GOLDAIR_PROPERTY_TO_DPS_ID[ATTR_ECO_TARGET_TEMPERATURE], target_temperature)
+
+    @property
+    def current_temperature(self):
+        """Return the current temperature."""
+        return self._device.get_property(GOLDAIR_PROPERTY_TO_DPS_ID[ATTR_TEMPERATURE])
+
+    @property
+    def hvac_mode(self):
+        """Return current HVAC mode, ie Heat or Off."""
+        dps_mode = self._device.get_property(GOLDAIR_PROPERTY_TO_DPS_ID[ATTR_HVAC_MODE])
+
+        if dps_mode is not None:
+            return GoldairTuyaDevice.get_key_for_value(GOLDAIR_MODE_TO_HVAC_MODE, dps_mode)
+        else:
+            return STATE_UNAVAILABLE
+
+    @property
+    def hvac_modes(self):
+        """Return the list of available HVAC modes."""
+        return list(GOLDAIR_MODE_TO_HVAC_MODE.keys())
+
+    def set_hvac_mode(self, hvac_mode):
+        """Set new HVAC mode."""
+        dps_mode = GOLDAIR_MODE_TO_HVAC_MODE[hvac_mode]
+        self._device.set_property(GOLDAIR_PROPERTY_TO_DPS_ID[ATTR_HVAC_MODE], dps_mode)
+
+    @property
+    def preset_mode(self):
+        """Return current preset mode, ie Comfort, Eco, Anti-freeze."""
+        dps_mode = self._device.get_property(GOLDAIR_PROPERTY_TO_DPS_ID[ATTR_PRESET_MODE])
+        if dps_mode is not None:
+            return GoldairTuyaDevice.get_key_for_value(GOLDAIR_MODE_TO_PRESET_MODE, dps_mode)
+        else:
+            return None
+
+    @property
+    def preset_modes(self):
+        """Return the list of available preset modes."""
+        return list(GOLDAIR_MODE_TO_PRESET_MODE.keys())
+
+    def set_preset_mode(self, preset_mode):
+        """Set new preset mode."""
+        dps_mode = GOLDAIR_MODE_TO_PRESET_MODE[preset_mode]
+        self._device.set_property(GOLDAIR_PROPERTY_TO_DPS_ID[ATTR_PRESET_MODE], dps_mode)
+
+    @property
+    def swing_mode(self):
+        """Return the power level."""
+        dps_mode = self._device.get_property(GOLDAIR_PROPERTY_TO_DPS_ID[ATTR_POWER_MODE])
+        if dps_mode == ATTR_POWER_MODE_USER:
+            return self._device.get_property(GOLDAIR_PROPERTY_TO_DPS_ID[ATTR_POWER_LEVEL])
+        elif dps_mode == ATTR_POWER_MODE_AUTO:
+            return GoldairTuyaDevice.get_key_for_value(GOLDAIR_POWER_LEVEL_TO_DPS_LEVEL, dps_mode)
+        else:
+            return None
+
+    @property
+    def swing_modes(self):
+        """List of power levels."""
+        return list(GOLDAIR_POWER_LEVEL_TO_DPS_LEVEL.keys())
+
+    def set_swing_mode(self, swing_mode):
+        """Set new power level."""
+        new_level = swing_mode
+        if new_level not in GOLDAIR_POWER_LEVEL_TO_DPS_LEVEL.keys():
+            raise ValueError(f'Invalid power level: {new_level}')
+        dps_level = GOLDAIR_POWER_LEVEL_TO_DPS_LEVEL[new_level]
+        self._device.set_property(GOLDAIR_PROPERTY_TO_DPS_ID[ATTR_POWER_LEVEL], dps_level)
+
+    def update(self):
+        self._device.refresh()

+ 58 - 0
custom_components/goldair_climate/heater/light.py

@@ -0,0 +1,58 @@
+"""
+Platform to control the LED display light on Goldair WiFi-connected heaters and panels.
+"""
+from homeassistant.components.light import Light
+from homeassistant.const import STATE_UNAVAILABLE
+from custom_components.goldair_climate import GoldairTuyaDevice
+from custom_components.goldair_climate.heater.climate import (
+  ATTR_DISPLAY_ON, GOLDAIR_PROPERTY_TO_DPS_ID, GOLDAIR_MODE_TO_HVAC_MODE
+)
+from homeassistant.components.climate import (
+  ATTR_HVAC_MODE, HVAC_MODE_OFF
+)
+
+import logging
+_LOGGER = logging.getLogger(__name__)
+
+class GoldairHeaterLedDisplayLight(Light):
+    """Representation of a Goldair WiFi-connected heater LED display."""
+
+    def __init__(self, device):
+        """Initialize the light.
+        Args:
+            device (GoldairTuyaDevice): The device API instance."""
+        self._device = device
+
+    @property
+    def should_poll(self):
+        """Return the polling state."""
+        return True
+
+    @property
+    def name(self):
+        """Return the name of the light."""
+        return self._device.name
+
+    @property
+    def is_on(self):
+        """Return the current state."""
+        dps_hvac_mode = self._device.get_property(GOLDAIR_PROPERTY_TO_DPS_ID[ATTR_HVAC_MODE])
+        dps_display_on = self._device.get_property(GOLDAIR_PROPERTY_TO_DPS_ID[ATTR_DISPLAY_ON])
+
+        if dps_hvac_mode is None or dps_hvac_mode == GOLDAIR_MODE_TO_HVAC_MODE[HVAC_MODE_OFF]:
+            return STATE_UNAVAILABLE
+        else:
+            return dps_display_on
+
+    def turn_on(self):
+        self._device.set_property(GOLDAIR_PROPERTY_TO_DPS_ID[ATTR_DISPLAY_ON], True)
+
+    def turn_off(self):
+        self._device.set_property(GOLDAIR_PROPERTY_TO_DPS_ID[ATTR_DISPLAY_ON], False)
+
+    def toggle(self):
+        dps_hvac_mode = self._device.get_property(GOLDAIR_PROPERTY_TO_DPS_ID[ATTR_HVAC_MODE])
+        dps_display_on = self._device.get_property(GOLDAIR_PROPERTY_TO_DPS_ID[ATTR_DISPLAY_ON])
+
+        if dps_hvac_mode != GOLDAIR_MODE_TO_HVAC_MODE[HVAC_MODE_OFF]:
+            self.turn_on() if not dps_display_on else self.turn_off()

+ 49 - 0
custom_components/goldair_climate/heater/lock.py

@@ -0,0 +1,49 @@
+"""
+Platform to control the child lock on Goldair WiFi-connected heaters and panels.
+"""
+from homeassistant.components.lock import (STATE_LOCKED, STATE_UNLOCKED, LockDevice)
+from homeassistant.const import STATE_UNAVAILABLE
+from custom_components.goldair_climate import GoldairTuyaDevice
+from custom_components.goldair_climate.heater.climate import (
+  ATTR_CHILD_LOCK, GOLDAIR_PROPERTY_TO_DPS_ID, GOLDAIR_MODE_TO_HVAC_MODE
+)
+
+class GoldairHeaterChildLock(LockDevice):
+    """Representation of a Goldair WiFi-connected heater child lock."""
+
+    def __init__(self, device):
+        """Initialize the lock.
+        Args:
+            device (GoldairTuyaDevice): The device API instance."""
+        self._device = device
+
+    @property
+    def should_poll(self):
+        """Return the polling state."""
+        return True
+
+    @property
+    def name(self):
+        """Return the name of the lock."""
+        return self._device.name
+
+    @property
+    def state(self):
+        """Return the current state."""
+        if self.is_locked is None:
+            return STATE_UNAVAILABLE
+        else:
+            return STATE_LOCKED if self.is_locked else STATE_UNLOCKED
+
+    @property
+    def is_locked(self):
+        """Return the a boolean representing whether the child lock is on or not."""
+        return self._device.get_property(GOLDAIR_PROPERTY_TO_DPS_ID[ATTR_CHILD_LOCK])
+
+    def lock(self, **kwargs):
+        """Turn on the child lock."""
+        self._device.set_property(GOLDAIR_PROPERTY_TO_DPS_ID[ATTR_CHILD_LOCK], True)
+
+    def unlock(self, **kwargs):
+        """Turn off the child lock."""
+        self._device.set_property(GOLDAIR_PROPERTY_TO_DPS_ID[ATTR_CHILD_LOCK], False)

+ 41 - 0
custom_components/goldair_climate/heater/sensor.py

@@ -0,0 +1,41 @@
+"""
+Platform to sense the current temperature at a Goldair WiFi-connected heaters and panels.
+"""
+from homeassistant.helpers.entity import Entity
+from homeassistant.const import (
+    STATE_UNAVAILABLE, ATTR_TEMPERATURE
+)
+from custom_components.goldair_climate import GoldairTuyaDevice
+from custom_components.goldair_climate.heater.climate import GOLDAIR_PROPERTY_TO_DPS_ID
+
+class GoldairHeaterTemperatureSensor(Entity):
+    """Representation of a Goldair WiFi-connected heater thermometer."""
+
+    def __init__(self, device):
+        """Initialize the lock.
+        Args:
+            device (GoldairTuyaDevice): The device API instance."""
+        self._device = device
+
+    @property
+    def should_poll(self):
+        """Return the polling state."""
+        return True
+
+    @property
+    def name(self):
+        """Return the name of the sensor."""
+        return self._device.name
+
+    @property
+    def state(self):
+        """Return the current temperature."""
+        current_temperature = self._device.get_property(GOLDAIR_PROPERTY_TO_DPS_ID[ATTR_TEMPERATURE])
+        if current_temperature is None:
+            return STATE_UNAVAILABLE
+        else:
+            return current_temperature
+
+    @property
+    def unit_of_measurement(self):
+        return self._device.temperature_unit

+ 14 - 52
custom_components/goldair_climate/light.py

@@ -1,52 +1,14 @@
-"""
-Platform to control the LED display light on Goldair WiFi-connected heaters and panels.
-"""
-from homeassistant.components.light import Light
-from homeassistant.const import STATE_UNAVAILABLE
-import custom_components.goldair_climate as goldair_climate
-
-
-def setup_platform(hass, config, add_devices, discovery_info=None):
-    device = hass.data[goldair_climate.DOMAIN][discovery_info['host']]
-    add_devices([
-        GoldairLedDisplayLight(device)
-    ])
-
-
-class GoldairLedDisplayLight(Light):
-    """Representation of a Goldair WiFi-connected heater LED display."""
-
-    def __init__(self, device):
-        """Initialize the light.
-        Args:
-            device (GoldairHeaterDevice): The device API instance."""
-        self._device = device
-
-    @property
-    def should_poll(self):
-        """Return the polling state."""
-        return True
-
-    @property
-    def name(self):
-        """Return the name of the light."""
-        return self._device.name
-
-    @property
-    def is_on(self):
-        """Return the current state."""
-        if self._device.is_on is None:
-            return STATE_UNAVAILABLE
-        else:
-            return self._device.is_on and self._device.is_display_on
-
-    def turn_on(self):
-        """Turn on the LED display."""
-        self._device.turn_display_on()
-
-    def turn_off(self):
-        """Turn off the LED display."""
-        self._device.turn_display_off()
-
-    def toggle(self):
-        self._device.turn_display_on() if not self._device.is_display_on else self._device.turn_display_off()
+"""
+Setup for different kinds of Goldair climate devices
+"""
+from homeassistant.const import CONF_HOST
+from custom_components.goldair_climate import (
+    DOMAIN, CONF_TYPE, CONF_TYPE_HEATER
+)
+from custom_components.goldair_climate.heater.light import GoldairHeaterLedDisplayLight
+
+def setup_platform(hass, config, add_devices, discovery_info=None):
+    """Set up the Goldair climate device according to its type."""
+    device = hass.data[DOMAIN][discovery_info[CONF_HOST]]
+    if discovery_info[CONF_TYPE] == CONF_TYPE_HEATER:
+        add_devices([GoldairHeaterLedDisplayLight(device)])

+ 14 - 54
custom_components/goldair_climate/lock.py

@@ -1,54 +1,14 @@
-"""
-Platform to control the child lock on Goldair WiFi-connected heaters and panels.
-"""
-from homeassistant.components.lock import (STATE_LOCKED, STATE_UNLOCKED, LockDevice)
-from homeassistant.const import STATE_UNAVAILABLE
-import custom_components.goldair_climate as goldair_climate
-
-
-def setup_platform(hass, config, add_devices, discovery_info=None):
-    device = hass.data[goldair_climate.DOMAIN][discovery_info['host']]
-    add_devices([
-        GoldairChildLock(device)
-    ])
-
-
-class GoldairChildLock(LockDevice):
-    """Representation of a Goldair WiFi-connected heater child lock."""
-
-    def __init__(self, device):
-        """Initialize the lock.
-        Args:
-            device (GoldairHeaterDevice): The device API instance."""
-        self._device = device
-
-    @property
-    def should_poll(self):
-        """Return the polling state."""
-        return True
-
-    @property
-    def name(self):
-        """Return the name of the lock."""
-        return self._device.name
-
-    @property
-    def state(self):
-        """Return the current state."""
-        if self.is_locked is None:
-            return STATE_UNAVAILABLE
-        else:
-            return STATE_LOCKED if self.is_locked else STATE_UNLOCKED
-
-    @property
-    def is_locked(self):
-        """Return the current state."""
-        return self._device.is_child_locked
-
-    def lock(self, code):
-        """Turn on the LED display."""
-        self._device.enable_child_lock()
-
-    def unlock(self, code):
-        """Turn off the LED display."""
-        self._device.disable_child_lock()
+"""
+Setup for different kinds of Goldair climate devices
+"""
+from homeassistant.const import CONF_HOST
+from custom_components.goldair_climate import (
+    DOMAIN, CONF_TYPE, CONF_TYPE_HEATER
+)
+from custom_components.goldair_climate.heater.lock import GoldairHeaterChildLock
+
+def setup_platform(hass, config, add_devices, discovery_info=None):
+    """Set up the Goldair climate device according to its type."""
+    device = hass.data[DOMAIN][discovery_info[CONF_HOST]]
+    if discovery_info[CONF_TYPE] == CONF_TYPE_HEATER:
+        add_devices([GoldairHeaterChildLock(device)])

+ 9 - 0
custom_components/goldair_climate/manifest.json

@@ -0,0 +1,9 @@
+{
+  "domain": "goldair_climate",
+  "name": "goldair_climate",
+  "documentation": "https://github.com/nikrolls/homeassistant-goldair-climate",
+  "dependencies": [],
+  "codeowners": ["@nikrolls"],
+  "requirements": ["pytuya>=7.0.5"],
+  "homeassistant": "0.96.0"
+}

+ 14 - 45
custom_components/goldair_climate/sensor.py

@@ -1,45 +1,14 @@
-"""
-Platform to sense the current temperature at a Goldair WiFi-connected heaters and panels.
-"""
-from homeassistant.helpers.entity import Entity
-from homeassistant.const import STATE_UNAVAILABLE
-import custom_components.goldair_climate as goldair_climate
-
-
-def setup_platform(hass, config, add_devices, discovery_info=None):
-    device = hass.data[goldair_climate.DOMAIN][discovery_info['host']]
-    add_devices([
-        GoldairTemperatureSensor(device)
-    ])
-
-
-class GoldairTemperatureSensor(Entity):
-    """Representation of a Goldair WiFi-connected heater thermometer."""
-
-    def __init__(self, device):
-        """Initialize the lock.
-        Args:
-            device (GoldairHeaterDevice): The device API instance."""
-        self._device = device
-
-    @property
-    def should_poll(self):
-        """Return the polling state."""
-        return True
-
-    @property
-    def name(self):
-        """Return the name of the sensor."""
-        return self._device.name
-
-    @property
-    def state(self):
-        """Return the current state."""
-        if self._device.current_temperature is None:
-            return STATE_UNAVAILABLE
-        else:
-            return self._device.current_temperature
-
-    @property
-    def unit_of_measurement(self):
-        return self._device.temperature_unit
+"""
+Setup for different kinds of Goldair climate devices
+"""
+from homeassistant.const import CONF_HOST
+from custom_components.goldair_climate import (
+    DOMAIN, CONF_TYPE, CONF_TYPE_HEATER
+)
+from custom_components.goldair_climate.heater.sensor import GoldairHeaterTemperatureSensor
+
+def setup_platform(hass, config, add_devices, discovery_info=None):
+    """Set up the Goldair climate device according to its type."""
+    device = hass.data[DOMAIN][discovery_info[CONF_HOST]]
+    if discovery_info[CONF_TYPE] == CONF_TYPE_HEATER:
+        add_devices([GoldairHeaterTemperatureSensor(device)])

+ 22 - 15
custom_updater.json

@@ -1,15 +1,22 @@
-{
-    "goldair_climate": {
-        "version": "0.0.3",
-        "local_location": "/custom_components/goldair_climate/__init__.py",
-        "remote_location": "https://raw.githubusercontent.com/nikrolls/homeassistant-goldair-climate/master/custom_components/goldair_climate/__init__.py",
-        "visit_repo": "https://github.com/nikrolls/homeassistant-goldair-climate",
-        "changelog": "https://github.com/nikrolls/homeassistant-goldair-climate/releases/latest",
-        "resources": [
-            "https://raw.githubusercontent.com/nikrolls/homeassistant-goldair-climate/master/custom_components/goldair_climate/climate.py",
-            "https://raw.githubusercontent.com/nikrolls/homeassistant-goldair-climate/master/custom_components/goldair_climate/light.py",
-            "https://raw.githubusercontent.com/nikrolls/homeassistant-goldair-climate/master/custom_components/goldair_climate/lock.py",
-            "https://raw.githubusercontent.com/nikrolls/homeassistant-goldair-climate/master/custom_components/goldair_climate/sensor.py"
-        ]
-    }
-}
+{
+    "goldair_climate": {
+        "version": "0.0.4",
+        "local_location": "/custom_components/goldair_climate/__init__.py",
+        "remote_location": "https://raw.githubusercontent.com/nikrolls/homeassistant-goldair-climate/master/custom_components/goldair_climate/__init__.py",
+        "visit_repo": "https://github.com/nikrolls/homeassistant-goldair-climate",
+        "changelog": "https://github.com/nikrolls/homeassistant-goldair-climate/releases/latest",
+        "resources": [
+            "https://raw.githubusercontent.com/nikrolls/homeassistant-goldair-climate/master/custom_components/goldair_climate/manifest.json",
+            "https://raw.githubusercontent.com/nikrolls/homeassistant-goldair-climate/master/custom_components/goldair_climate/__init__.py",
+            "https://raw.githubusercontent.com/nikrolls/homeassistant-goldair-climate/master/custom_components/goldair_climate/climate.py",
+            "https://raw.githubusercontent.com/nikrolls/homeassistant-goldair-climate/master/custom_components/goldair_climate/light.py",
+            "https://raw.githubusercontent.com/nikrolls/homeassistant-goldair-climate/master/custom_components/goldair_climate/lock.py",
+            "https://raw.githubusercontent.com/nikrolls/homeassistant-goldair-climate/master/custom_components/goldair_climate/sensor.py",
+            "https://raw.githubusercontent.com/nikrolls/homeassistant-goldair-climate/master/custom_components/goldair_climate/heater/__init__.py",
+            "https://raw.githubusercontent.com/nikrolls/homeassistant-goldair-climate/master/custom_components/goldair_climate/heater/climate.py",
+            "https://raw.githubusercontent.com/nikrolls/homeassistant-goldair-climate/master/custom_components/goldair_climate/heater/light.py",
+            "https://raw.githubusercontent.com/nikrolls/homeassistant-goldair-climate/master/custom_components/goldair_climate/heater/lock.py",
+            "https://raw.githubusercontent.com/nikrolls/homeassistant-goldair-climate/master/custom_components/goldair_climate/heater/sensor.py"
+        ]
+    }
+}