Browse Source

Convert to a component that can expose climate, sensor, light and lock

Nik Rolls 7 năm trước cách đây
mục cha
commit
04d092b6fb

+ 79 - 31
README.md

@@ -1,34 +1,42 @@
-Home Assistant Goldair WiFi Heater platform
-===========================================
+Home Assistant Goldair WiFi Heater component
+============================================
 
 
-The `goldair_heater` platform integrates [Goldair WiFi-enabled heaters](http://www.goldair.co.nz/product-catalogue/heating/wifi-heaters) into Home Assistant, enabling control of setting the following parameters via the UI and climate service:
+The `goldair_heater` component integrates 
+[Goldair WiFi-enabled heaters](http://www.goldair.co.nz/product-catalogue/heating/wifi-heaters) into Home Assistant, 
+enabling control of setting the following parameters via the UI and the following services:
 
 
+**Climate**
 * **power** (on/off)
 * **power** (on/off)
 * **mode** (Comfort, Eco, Anti-freeze)
 * **mode** (Comfort, Eco, Anti-freeze)
-* **target temperature** (`5`-`35` representing °C)
+* **target temperature** (`5`-`35` in Comfort mode, `5`-`21` in Eco mode, in °C)
+* **power level** (via the swing mode setting because no appropriate HA option exists: `Auto`, `1`-`5`, `Stop`)
 
 
-and additionally the following parameters via `configuration.yaml`:
+Current temperature is also displayed.
 
 
-* **power_level** (auto, `1`-`5`)
-* **display** (on/off)
-* **child_lock** (on/off)
+**Sensor**
+* **current temperature** (in °C)
 
 
-Current temperature is displayed.
+**Light**
+* **LED display** (on/off)
+
+**Lock**
+* **Child lock** (on/off)
 
 
 ---
 ---
 
 
 ### Warning
 ### Warning
-Please note, this platform has currently only been tested with the Goldair GPPH (inverter) range, however theoretically it should also work with GEPH and GPCV devices and any other Goldair heaters based on the Tuya platform.
+Please note, this component has currently only been tested with the Goldair GPPH (inverter) range, however theoretically 
+it should also work with GEPH and GPCV devices and any other Goldair heaters based on the Tuya platform.
 
 
 ---
 ---
 
 
-To enable the platform, copy `goldair_heater.py` to the `<config_dir>/custom_components/climate` directory, then add the following lines to your `configuration.yaml` file:
+To enable the component, copy the contents of this repository's `component` directory to your
+`<config_dir>/custom_components` directory, then add the following lines to your `configuration.yaml` file:
 
 
 ```yaml
 ```yaml
 # Example configuration.yaml entry
 # Example configuration.yaml entry
-climate:
-  - platform: goldair_heater
-    name: My heater
+goldair_heater:
+  - name: My heater
     host: 1.2.3.4
     host: 1.2.3.4
     device_id: <your device id>
     device_id: <your device id>
     local_key: <your local key>
     local_key: <your local key>
@@ -38,7 +46,8 @@ CONFIGURATION VARIABLES
 -----------------------
 -----------------------
 
 
 ### name
 ### name
-&nbsp;&nbsp;&nbsp;&nbsp;*(string) (Required)* Any unique for the device; required because the Tuya API doesn't provide the one you set in the app.
+&nbsp;&nbsp;&nbsp;&nbsp;*(string) (Required)* Any unique for the device; required because the Tuya API doesn't provide
+                                              the one you set in the app.
 
 
 ### host
 ### host
 &nbsp;&nbsp;&nbsp;&nbsp;*(string) (Required)* IP or hostname of the device.
 &nbsp;&nbsp;&nbsp;&nbsp;*(string) (Required)* IP or hostname of the device.
@@ -49,36 +58,75 @@ CONFIGURATION VARIABLES
 ### local_key
 ### local_key
 &nbsp;&nbsp;&nbsp;&nbsp;*(string) (Required)* Local key retrieved from the Goldair app logs (see below).
 &nbsp;&nbsp;&nbsp;&nbsp;*(string) (Required)* Local key retrieved from the Goldair app logs (see below).
 
 
-### power_level
-&nbsp;&nbsp;&nbsp;&nbsp;*(string) (Optional)* Set the power level you want to use. `auto` is recommended, though the heaters also support `stop` (no heat at all) or `1` through `5`. Bear in mind that this is a once-off setting because the HA climate interface doesn't yet have a control or service that suits this property.
+### climate
+&nbsp;&nbsp;&nbsp;&nbsp;*(boolean) (Optional)* Whether to surface this heater as a climate device.
+
+&nbsp;&nbsp;&nbsp;&nbsp;*Default value: true* 
+
+### sensor
+&nbsp;&nbsp;&nbsp;&nbsp;*(boolean) (Optional)* Whether to surface this heater's thermometer as a temperature sensor.
+
+&nbsp;&nbsp;&nbsp;&nbsp;*Default value: false* 
+
+### display_light
+&nbsp;&nbsp;&nbsp;&nbsp;*(boolean) (Optional)* Whether to surface this heater's LED display control as a light.
 
 
-### display_on
-&nbsp;&nbsp;&nbsp;&nbsp;*(boolean) (Optional)* Whether to enable the LED display or not. 
+&nbsp;&nbsp;&nbsp;&nbsp;*Default value: false* 
 
 
 ### child_lock
 ### child_lock
-&nbsp;&nbsp;&nbsp;&nbsp;*(boolean) (Optional)* Whether to enable the child lock or not. 
+&nbsp;&nbsp;&nbsp;&nbsp;*(boolean) (Optional)* Whether to surface this heater's child lock as a lock device.
+
+&nbsp;&nbsp;&nbsp;&nbsp;*Default value: false* 
+
+GOTCHAS
+-------
+These heaters have individual target temperatures for their Comfort and Eco modes, whereas Home Assistant only supports
+a single target temperature. Therefore, when you're in Comfort mode you will set the Comfort temperature (`5`-`35`), and
+when you're in Eco mode you will set the Eco temperature (`5`-`21`), just like you were using the heater's own control 
+panel. Bear this in mind when writing automations that change the operation mode and set a temperature at the same time: 
+you must change the operation mode *before* setting the new target temperature, otherwise you will set the current 
+thermostat rather than the new one. 
+
+When switching to Anti-freeze mode, the heater will set the current power level to `1` as if you had manually chosen it.
+When you switch back to other modes, you will no longer be in `Auto` and will have to set it again if this is what you
+wanted. This could be worked around in code however it would require storing state that may be cleared if HA is
+restarted and due to this unreliability it's probably best that you just factor it into your automations.
+
+When child lock is enabled, the heater's display will flash with the child lock symbol (`[]`) whenever you change
+something in HA. This can be confusing because it's the same behaviour as when you try to change something via the
+heater's own control panel and the change is rejected due to being locked, however rest assured that the changes *are* 
+taking effect.
 
 
 FINDING YOUR DEVICE ID AND LOCAL KEY
 FINDING YOUR DEVICE ID AND LOCAL KEY
 ------------------------------------
 ------------------------------------
 
 
-If you have an Android device that supports Mass Storage mode, you can easily find these properties using the below instructions. If you don't, there are some alternate methods at [codetheweb/tuyapi](https://github.com/codetheweb/tuyapi/blob/master/docs/SETUP.md) (you're looking for the `uuid` and `localKey` values).
+If you have an Android device that supports Mass Storage mode, you can easily find these properties using the below 
+instructions. If you don't, there are some alternate methods at 
+[codetheweb/tuyapi](https://github.com/codetheweb/tuyapi/blob/master/docs/SETUP.md) (you're looking for the `uuid` and
+`localKey` values).
 
 
 1. Download the [Goldair app from the Play Store](https://play.google.com/store/apps/details?id=com.goldair.smart).
 1. Download the [Goldair app from the Play Store](https://play.google.com/store/apps/details?id=com.goldair.smart).
-2. Follow the instructions in the app to set up the heater. Don't agonise over the name because you'll be giving it a new one in HA, but do remember it because you'll use this name to find the keys later.
-3. Once this is done and you've verified that you can control the heater from your phone, close the app and plug your phone into a computer in Mass Storage mode (choose the option to browse files). 
-    * ℹ Alternatively you can use an Android file browser, but bear in mind you will need to search through a large log file
-4. Browse your phone's filesystem to find `/Android/data/com.goldair.smart/cache/1.abj` and open it in a text editor that can handle large files.
-5. Search for your device in this file by the name you gave it earlier. You're looking for a very long line that contains not only the device name, but also `uuid` and `localKey` properties. 
-    * ℹ If you've been using the app a while and have added this device more than once, you need to find the last occurrence of this kind of line for your device in the log file
-6. Copy the value of `uuid` (eg: 1234567890abcdef1234) to `device_id`, and the value of `localKey` (eg: 1234567890abcdef) to `local_key` in your `configuration.yaml` file.
+2. Follow the instructions in the app to set up the heater. Don't agonise over the name because you'll be giving it a 
+   new one in HA, but do remember it because you'll use this name to find the keys later.
+3. Once this is done and you've verified that you can control the heater from your phone, close the app and plug your 
+   phone into a computer in Mass Storage mode (choose the option to browse files). 
+    * ℹ Alternatively you can use an Android file browser, but bear in mind you will need to search through a large log
+        file
+4. Browse your phone's filesystem to find `/Android/data/com.goldair.smart/cache/1.abj` and open it in a text editor 
+   that can handle large files.
+5. Search for your device in this file by the name you gave it earlier. You're looking for a very long line that 
+   contains not only the device name, but also `uuid` and `localKey` properties. 
+    * ℹ If you've been using the app a while and have added this device more than once, you need to find the last 
+        occurrence of this kind of line for your device in the log file
+6. Copy the value of `uuid` (eg: 1234567890abcdef1234) to `device_id`, and the value of `localKey` 
+   (eg: 1234567890abcdef) to `local_key` in your `configuration.yaml` file.
 
 
 Repeat for as many heaters as you have to set up.
 Repeat for as many heaters as you have to set up.
 
 
 NEXT STEPS
 NEXT STEPS
 ----------
 ----------
-This platform needs specs! Once they're written I'm considering submitting it to the HA team for inclusion in standard installations. Please report any issues and feel free to raise pull requests.
-
-It would be great to support changing of the power level and setting of the timer via the HA interface and automation. Any ideas on how to do this would be much appreciated; I suspect it may end up being a combination of multiple platform types.
+This component needs specs! Once they're written I'm considering submitting it to the HA team for inclusion in standard 
+installations. Please report any issues and feel free to raise pull requests.
 
 
 This was my first Python project, so feel free to correct any conventions or idioms I got wrong.
 This was my first Python project, so feel free to correct any conventions or idioms I got wrong.
 
 

+ 137 - 0
component/climate/goldair_heater.py

@@ -0,0 +1,137 @@
+"""
+Platform to control Goldair WiFi-connected heaters and panels.
+"""
+from homeassistant.components.climate import (
+    ClimateDevice,
+    ATTR_OPERATION_MODE, ATTR_TEMPERATURE,
+    SUPPORT_ON_OFF, SUPPORT_TARGET_TEMPERATURE, SUPPORT_OPERATION_MODE, SUPPORT_SWING_MODE
+)
+from homeassistant.const import STATE_UNAVAILABLE
+import custom_components.goldair_heater as goldair_heater
+
+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_heater.DOMAIN][discovery_info['host']]
+    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()

+ 462 - 506
goldair_heater.py → component/goldair_heater.py

@@ -1,506 +1,462 @@
-"""
-Platform to control Goldair WiFi-connected digital inverter heaters.
-
-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.
-"""
-import logging
-import json
-from time import time
-from threading import Timer, Lock
-
-import voluptuous as vol
-from homeassistant.components.climate import (
-    ClimateDevice, PLATFORM_SCHEMA,
-    ATTR_OPERATION_MODE, ATTR_TEMPERATURE,
-    SUPPORT_ON_OFF, SUPPORT_TARGET_TEMPERATURE, SUPPORT_OPERATION_MODE)
-from homeassistant.const import (
-    CONF_NAME, CONF_HOST, STATE_ON, STATE_OFF, STATE_UNAVAILABLE, TEMP_CELSIUS
-)
-import homeassistant.helpers.config_validation as cv
-
-REQUIREMENTS = ['pytuya==7.0']
-
-_LOGGER = logging.getLogger(__name__)
-
-CONF_DEVICE_ID = 'device_id'
-CONF_LOCAL_KEY = 'local_key'
-CONF_ADDITIONAL_PROPERTIES = 'additional_properties'
-CONF_POWER_LEVEL = 'power_level'
-CONF_CHILD_LOCK = 'child_lock'
-CONF_DISPLAY_ON = 'display_on'
-
-ATTR_ON = 'on'
-ATTR_TARGET_TEMPERATURE = 'target_temperature'
-
-STATE_COMFORT = 'Comfort'
-STATE_ECO = 'Eco'
-STATE_ANTI_FREEZE = 'Anti-freeze'
-
-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_USER_MODE = 'user_mode'  # not sure what this does
-ATTR_ECO_TARGET_TEMPERATURE = 'eco_' + ATTR_TARGET_TEMPERATURE
-
-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_USER_MODE: '105',  # not sure what this does
-    ATTR_ECO_TARGET_TEMPERATURE: '106'
-}
-
-GOLDAIR_MODE_TO_DPS_MODE = {
-    STATE_COMFORT: 'C',
-    STATE_ECO: 'ECO',
-    STATE_ANTI_FREEZE: 'AF'
-}
-GOLDAIR_POWER_LEVELS = ['stop', '1', '2', '3', '4', '5', 'auto']
-GOLDAIR_USER_MODES = ['auto', 'user']  # not sure what this does
-
-PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
-    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.Optional(CONF_POWER_LEVEL): vol.All(vol.Coerce(str), vol.In(GOLDAIR_POWER_LEVELS)),
-    vol.Optional(CONF_CHILD_LOCK): cv.boolean,
-    vol.Optional(CONF_DISPLAY_ON): cv.boolean
-})
-
-SUPPORT_FLAGS = SUPPORT_ON_OFF | SUPPORT_TARGET_TEMPERATURE | SUPPORT_OPERATION_MODE
-
-
-def setup_platform(hass, config, add_devices, discovery_info=None):
-    """Set up the Goldair WiFi heater."""
-    device = GoldairHeaterDevice(
-        config.get(CONF_DEVICE_ID),
-        config.get(CONF_HOST),
-        config.get(CONF_LOCAL_KEY)
-    )
-
-    fixed_properties = {
-        ATTR_POWER_LEVEL: str(config.get(CONF_POWER_LEVEL)),
-        ATTR_CHILD_LOCK: config.get(CONF_CHILD_LOCK),
-        ATTR_DISPLAY_ON: config.get(CONF_DISPLAY_ON)
-    }
-    fixed_properties = {k: v for k, v in fixed_properties.items() if v is not None}
-    if len(fixed_properties) > 0:
-        device.set_fixed_properties(fixed_properties)
-
-    add_devices([
-        GoldairHeater(config.get(CONF_NAME), device)
-    ])
-
-
-class GoldairHeater(ClimateDevice):
-    """Representation of a Goldair WiFi heater."""
-
-    def __init__(self, name, device):
-        """Initialize the heater.
-        Args:
-            name (str): The device's name.
-            device (GoldairHeaterDevice): The device API instance."""
-        self._name = name
-        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._name
-
-    @property
-    def state(self):
-        """Return the current state."""
-        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)
-
-    def update(self):
-        self._device.refresh()
-
-
-# HA prefers this to be lazy-imported, however we can't do that because it needs to be subclassed
-import pytuya
-
-
-class GoldairHeaterDevice(pytuya.Device):
-    def __init__(self, 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.
-        """
-        super().__init__(dev_id, address, local_key, 'device')
-
-        self._fixed_properties = {}
-        self._reset_cached_state()
-
-        self._TEMPERATURE_UNIT = TEMP_CELSIUS
-        self._TEMPERATURE_STEP = 1
-        self._MIN_TARGET_TEMPERATURE = 5
-        self._MAX_TARGET_TEMPERATURE = 35
-
-        # 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 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):
-        return self._get_cached_state()[ATTR_TARGET_TEMPERATURE]
-
-    @property
-    def target_temperature_step(self):
-        return self._TEMPERATURE_STEP
-
-    @property
-    def min_target_teperature(self):
-        return self._MIN_TARGET_TEMPERATURE
-
-    @property
-    def max_target_temperature(self):
-        return self._MAX_TARGET_TEMPERATURE
-
-    def set_target_temperature(self, target_temperature):
-        target_temperature = int(round(target_temperature))
-        if not self._MIN_TARGET_TEMPERATURE <= target_temperature <= self._MAX_TARGET_TEMPERATURE:
-            raise ValueError(
-                f'Target temperature ({target_temperature}) must be between '
-                f'{self._MIN_TARGET_TEMPERATURE} and {self._MAX_TARGET_TEMPERATURE}'
-            )
-        self._set_properties({ATTR_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):
-        return self._get_cached_state()[ATTR_POWER_LEVEL]
-
-    def set_power_level(self, new_level):
-        if new_level not in GOLDAIR_POWER_LEVELS:
-            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 user_mode(self):
-        return self._get_cached_state()[ATTR_USER_MODE]
-
-    def set_user_mode(self, new_mode):
-        if new_mode not in GOLDAIR_USER_MODES:
-            raise ValueError(f'Invalid user mode: {new_mode}')
-        self._set_properties({ATTR_USER_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_USER_MODE: None,
-            ATTR_ECO_TARGET_TEMPERATURE: None,
-            'updated_at': 0
-        }
-        self._pending_updates = {}
-
-    def _refresh_cached_state(self):
-        new_state = self.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.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._send_receive(payload)
-            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)
-                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]
-                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, ATTR_TEMPERATURE, TEMP_CELSIUS)
+from homeassistant.components.climate import ATTR_OPERATION_MODE
+from homeassistant.helpers.discovery import load_platform
+
+REQUIREMENTS = ['pytuya==7.0']
+
+_LOGGER = logging.getLogger(__name__)
+
+DOMAIN = 'goldair_heater'
+DATA_GOLDAIR_HEATER = 'data_goldair_heater'
+
+
+CONF_DEVICE_ID = 'device_id'
+CONF_LOCAL_KEY = 'local_key'
+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.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('climate'):
+            load_platform(hass, 'climate', DOMAIN, {'host': host})
+        if device_config.get('sensor'):
+            load_platform(hass, 'sensor', DOMAIN, {'host': host})
+        if device_config.get('display_light'):
+            load_platform(hass, 'light', DOMAIN, {'host': host})
+        if device_config.get('child_lock'):
+            load_platform(hass, 'lock', DOMAIN, {'host': host})
+
+    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)]

+ 52 - 0
component/light/goldair_heater.py

@@ -0,0 +1,52 @@
+"""
+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_heater as goldair_heater
+
+
+def setup_platform(hass, config, add_devices, discovery_info=None):
+    device = hass.data[goldair_heater.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()

+ 54 - 0
component/lock/goldair_heater.py

@@ -0,0 +1,54 @@
+"""
+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_heater as goldair_heater
+
+
+def setup_platform(hass, config, add_devices, discovery_info=None):
+    device = hass.data[goldair_heater.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 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 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()

+ 45 - 0
component/sensor/goldair_heater.py

@@ -0,0 +1,45 @@
+"""
+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_heater as goldair_heater
+
+
+def setup_platform(hass, config, add_devices, discovery_info=None):
+    device = hass.data[goldair_heater.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 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 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