Merge remote-tracking branch 'upstream/master' into climate

This commit is contained in:
Martín Villagra
2021-12-28 23:51:35 -03:00
12 changed files with 283 additions and 10 deletions

View File

@@ -71,6 +71,7 @@ localtuya:
color_mode: 21 # Optional, usually 2 or 21, default: "none"
brightness: 22 # Optional, usually 3 or 22, default: "none"
color_temp: 23 # Optional, usually 4 or 23, default: "none"
color_temp_reverse: false # Optional, default: false
color: 24 # Optional, usually 5 (RGB_HSV) or 24 (HSV), default: "none"
brightness_lower: 29 # Optional, usually 0 or 29, default: 29
brightness_upper: 1000 # Optional, usually 255 or 1000, default: 1000
@@ -79,7 +80,6 @@ localtuya:
scene: 25 # Optional, usually 6 (RGB_HSV) or 25 (HSV), default: "none"
music_mode: False # Optional, some use internal mic, others, phone mic. Only internal mic is supported, default: "False"
- platform: sensor
friendly_name: Plug Voltage
id: 20
@@ -107,6 +107,8 @@ Start by going to Configuration - Integration and pressing the "+" button to cre
Wait for 6 seconds for the scanning of the devices in your LAN. Then, a drop-down menu will appear containing the list of detected devices: you can
select one of these, or manually input all the parameters.
> **Note: The tuya app on your device must be closed for the following steps to work reliably.**
![discovery](https://github.com/rospogrigio/localtuya-homeassistant/blob/master/img/1-discovery.png)
If you have selected one entry, you only need to input the device's Friendly Name and the localKey.

View File

@@ -116,6 +116,7 @@ class TuyaDevice(pytuya.TuyaListener, pytuya.ContextualLogger):
self.dps_to_request = {}
self._is_closing = False
self._connect_task = None
self._disconnect_task = None
self.set_logger(_LOGGER, config_entry[CONF_DEVICE_ID])
# This has to be done in case the device type is type_0d
@@ -133,6 +134,7 @@ class TuyaDevice(pytuya.TuyaListener, pytuya.ContextualLogger):
self._connect_task = asyncio.create_task(self._make_connection())
async def _make_connection(self):
"""Subscribe localtuya entity events."""
self.debug("Connecting to %s", self._config_entry[CONF_HOST])
try:
@@ -151,6 +153,19 @@ class TuyaDevice(pytuya.TuyaListener, pytuya.ContextualLogger):
raise Exception("Failed to retrieve status")
self.status_updated(status)
def _new_entity_handler(entity_id):
self.debug(
"New entity %s was added to %s",
entity_id,
self._config_entry[CONF_HOST],
)
self._dispatch_status()
signal = f"localtuya_entity_{self._config_entry[CONF_DEVICE_ID]}"
self._disconnect_task = async_dispatcher_connect(
self._hass, signal, _new_entity_handler
)
except Exception: # pylint: disable=broad-except
self.exception(f"Connect to {self._config_entry[CONF_HOST]} failed")
if self._interface is not None:
@@ -166,6 +181,8 @@ class TuyaDevice(pytuya.TuyaListener, pytuya.ContextualLogger):
await self._connect_task
if self._interface is not None:
await self._interface.close()
if self._disconnect_task is not None:
self._disconnect_task()
async def set_dp(self, state, dp_index):
"""Change value of a DP of the Tuya device."""
@@ -195,7 +212,9 @@ class TuyaDevice(pytuya.TuyaListener, pytuya.ContextualLogger):
def status_updated(self, status):
"""Device updated status."""
self._status.update(status)
self._dispatch_status()
def _dispatch_status(self):
signal = f"localtuya_{self._config_entry[CONF_DEVICE_ID]}"
async_dispatcher_send(self._hass, signal, self._status)
@@ -243,10 +262,14 @@ class LocalTuyaEntity(RestoreEntity, pytuya.ContextualLogger):
self.schedule_update_ha_state()
signal = f"localtuya_{self._config_entry.data[CONF_DEVICE_ID]}"
self.async_on_remove(
async_dispatcher_connect(self.hass, signal, _update_handler)
)
signal = f"localtuya_entity_{self._config_entry.data[CONF_DEVICE_ID]}"
async_dispatcher_send(self.hass, signal, self.entity_id)
@property
def device_info(self):
"""Return device information for the device registry."""

View File

@@ -16,6 +16,7 @@ CONF_COLOR = "color"
CONF_COLOR_MODE = "color_mode"
CONF_COLOR_TEMP_MIN_KELVIN = "color_temp_min_kelvin"
CONF_COLOR_TEMP_MAX_KELVIN = "color_temp_max_kelvin"
CONF_COLOR_TEMP_REVERSE = "color_temp_reverse"
CONF_MUSIC_MODE = "music_mode"
# switch
@@ -64,6 +65,16 @@ DATA_DISCOVERY = "discovery"
DOMAIN = "localtuya"
# Platforms in this list must support config flows
PLATFORMS = ["binary_sensor", "cover", "climate", "fan", "light", "sensor", "switch"]
PLATFORMS = [
"binary_sensor",
"climate",
"cover",
"fan",
"light",
"number",
"select",
"sensor",
"switch",
]
TUYA_DEVICE = "tuya_device"

View File

@@ -79,7 +79,13 @@ class LocaltuyaFan(LocalTuyaEntity, FanEntity):
"""Get the list of available speeds."""
return [SPEED_OFF, SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH]
async def async_turn_on(self, speed: str = None, **kwargs) -> None:
async def async_turn_on(
self,
speed: str = None,
percentage: int = None,
preset_mode: str = None,
**kwargs,
) -> None:
"""Turn on the entity."""
await self._device.set_dp(True, self._dp_id)
if speed is not None:

View File

@@ -27,6 +27,7 @@ from .const import (
CONF_COLOR_MODE,
CONF_COLOR_TEMP_MAX_KELVIN,
CONF_COLOR_TEMP_MIN_KELVIN,
CONF_COLOR_TEMP_REVERSE,
CONF_MUSIC_MODE,
)
@@ -36,6 +37,8 @@ MIRED_TO_KELVIN_CONST = 1000000
DEFAULT_MIN_KELVIN = 2700 # MIRED 370
DEFAULT_MAX_KELVIN = 6500 # MIRED 153
DEFAULT_COLOR_TEMP_REVERSE = False
DEFAULT_LOWER_BRIGHTNESS = 29
DEFAULT_UPPER_BRIGHTNESS = 1000
@@ -117,6 +120,11 @@ def flow_schema(dps):
vol.Optional(CONF_COLOR_TEMP_MAX_KELVIN, default=DEFAULT_MAX_KELVIN): vol.All(
vol.Coerce(int), vol.Range(min=1500, max=8000)
),
vol.Optional(
CONF_COLOR_TEMP_REVERSE,
default=DEFAULT_COLOR_TEMP_REVERSE,
description={"suggested_value": DEFAULT_COLOR_TEMP_REVERSE},
): bool,
vol.Optional(CONF_SCENE): vol.In(dps),
vol.Optional(
CONF_MUSIC_MODE, default=False, description={"suggested_value": False}
@@ -154,6 +162,9 @@ class LocaltuyaLight(LocalTuyaEntity, LightEntity):
MIRED_TO_KELVIN_CONST
/ self._config.get(CONF_COLOR_TEMP_MAX_KELVIN, DEFAULT_MAX_KELVIN)
)
self._color_temp_reverse = self._config.get(
CONF_COLOR_TEMP_REVERSE, DEFAULT_COLOR_TEMP_REVERSE
)
self._hs = None
self._effect = None
self._effect_list = []
@@ -199,11 +210,16 @@ class LocaltuyaLight(LocalTuyaEntity, LightEntity):
def color_temp(self):
"""Return the color_temp of the light."""
if self.has_config(CONF_COLOR_TEMP) and self.is_white_mode:
color_temp_value = (
self._upper_color_temp - self._color_temp
if self._color_temp_reverse
else self._color_temp
)
return int(
self._max_mired
- (
((self._max_mired - self._min_mired) / self._upper_color_temp)
* self._color_temp
* color_temp_value
)
)
return None
@@ -364,10 +380,16 @@ class LocaltuyaLight(LocalTuyaEntity, LightEntity):
if ATTR_COLOR_TEMP in kwargs and (features & SUPPORT_COLOR_TEMP):
if brightness is None:
brightness = self._brightness
color_temp_value = (
(self._max_mired - self._min_mired)
- (int(kwargs[ATTR_COLOR_TEMP]) - self._min_mired)
if self._color_temp_reverse
else (int(kwargs[ATTR_COLOR_TEMP]) - self._min_mired)
)
color_temp = int(
self._upper_color_temp
- (self._upper_color_temp / (self._max_mired - self._min_mired))
* (int(kwargs[ATTR_COLOR_TEMP]) - self._min_mired)
* color_temp_value
)
states[self._config.get(CONF_COLOR_MODE)] = MODE_WHITE
states[self._config.get(CONF_BRIGHTNESS)] = brightness

View File

@@ -0,0 +1,87 @@
"""Platform to present any Tuya DP as a number."""
import logging
from functools import partial
import voluptuous as vol
from homeassistant.components.number import DOMAIN, NumberEntity
from homeassistant.const import (
CONF_DEVICE_CLASS,
STATE_UNKNOWN,
)
from .common import LocalTuyaEntity, async_setup_entry
_LOGGER = logging.getLogger(__name__)
CONF_MIN_VALUE = "min_value"
CONF_MAX_VALUE = "max_value"
DEFAULT_MIN = 0
DEFAULT_MAX = 100000
def flow_schema(dps):
"""Return schema used in config flow."""
return {
vol.Optional(CONF_MIN_VALUE, default=DEFAULT_MIN): vol.All(
vol.Coerce(float),
vol.Range(min=-1000000.0, max=1000000.0),
),
vol.Required(CONF_MAX_VALUE, default=DEFAULT_MAX): vol.All(
vol.Coerce(float),
vol.Range(min=-1000000.0, max=1000000.0),
),
}
class LocaltuyaNumber(LocalTuyaEntity, NumberEntity):
"""Representation of a Tuya Number."""
def __init__(
self,
device,
config_entry,
sensorid,
**kwargs,
):
"""Initialize the Tuya sensor."""
super().__init__(device, config_entry, sensorid, _LOGGER, **kwargs)
self._state = STATE_UNKNOWN
self._min_value = DEFAULT_MIN
if CONF_MIN_VALUE in self._config:
self._min_value = self._config.get(CONF_MIN_VALUE)
self._max_value = self._config.get(CONF_MAX_VALUE)
@property
def value(self) -> float:
"""Return sensor state."""
return self._state
@property
def min_value(self) -> float:
"""Return the minimum value."""
return self._min_value
@property
def max_value(self) -> float:
"""Return the maximum value."""
return self._max_value
@property
def device_class(self):
"""Return the class of this device."""
return self._config.get(CONF_DEVICE_CLASS)
async def async_set_value(self, value: float) -> None:
"""Update the current value."""
await self._device.set_dp(value, self._dp_id)
def status_updated(self):
"""Device status was updated."""
state = self.dps(self._dp_id)
self._state = state
async_setup_entry = partial(async_setup_entry, DOMAIN, LocaltuyaNumber, flow_schema)

View File

@@ -0,0 +1,103 @@
"""Platform to present any Tuya DP as an enumeration."""
import logging
from functools import partial
import voluptuous as vol
from homeassistant.components.select import DOMAIN, SelectEntity
from homeassistant.const import (
CONF_DEVICE_CLASS,
STATE_UNKNOWN,
)
from .common import LocalTuyaEntity, async_setup_entry
_LOGGER = logging.getLogger(__name__)
CONF_OPTIONS = "select_options"
CONF_OPTIONS_FRIENDLY = "select_options_friendly"
def flow_schema(dps):
"""Return schema used in config flow."""
return {
vol.Required(CONF_OPTIONS): str,
vol.Optional(CONF_OPTIONS_FRIENDLY): str,
}
class LocaltuyaSelect(LocalTuyaEntity, SelectEntity):
"""Representation of a Tuya Enumeration."""
def __init__(
self,
device,
config_entry,
sensorid,
**kwargs,
):
"""Initialize the Tuya sensor."""
super().__init__(device, config_entry, sensorid, _LOGGER, **kwargs)
self._state = STATE_UNKNOWN
self._state_friendly = ""
self._valid_options = self._config.get(CONF_OPTIONS).split(";")
# Set Display options
self._display_options = []
display_options_str = ""
if CONF_OPTIONS_FRIENDLY in self._config:
display_options_str = self._config.get(CONF_OPTIONS_FRIENDLY).strip()
_LOGGER.debug("Display Options Configured: %s", display_options_str)
if display_options_str.find(";") >= 0:
self._display_options = display_options_str.split(";")
elif len(display_options_str.strip()) > 0:
self._display_options.append(display_options_str)
else:
# Default display string to raw string
_LOGGER.debug("No Display options configured - defaulting to raw values")
self._display_options = self._valid_options
_LOGGER.debug(
"Total Raw Options: %s - Total Display Options: %s",
str(len(self._valid_options)),
str(len(self._display_options)),
)
if len(self._valid_options) > len(self._display_options):
# If list of display items smaller than list of valid items,
# then default remaining items to be the raw value
_LOGGER.debug(
"Valid options is larger than display options - \
filling up with raw values"
)
for i in range(len(self._display_options), len(self._valid_options)):
self._display_options.append(self._valid_options[i])
@property
def current_option(self) -> str:
"""Return the current value."""
return self._state_friendly
@property
def options(self) -> list:
"""Return the list of values."""
return self._display_options
@property
def device_class(self):
"""Return the class of this device."""
return self._config.get(CONF_DEVICE_CLASS)
async def async_select_option(self, option: str) -> None:
"""Update the current value."""
option_value = self._valid_options[self._display_options.index(option)]
_LOGGER.debug("Sending Option: " + option + " -> " + option_value)
await self._device.set_dp(option_value, self._dp_id)
def status_updated(self):
"""Device status was updated."""
state = self.dps(self._dp_id)
self._state_friendly = self._display_options[self._valid_options.index(state)]
self._state = state
async_setup_entry = partial(async_setup_entry, DOMAIN, LocaltuyaSelect, flow_schema)

View File

@@ -75,6 +75,10 @@
"fan_speed_low": "Fan Low Speed Setting",
"fan_speed_medium": "Fan Medium Speed Setting",
"fan_speed_high": "Fan High Speed Setting",
"max_value": "Maximum Value",
"min_value": "Minimum Value",
"select_options": "Valid entries, separate entries by a ;",
"select_options_friendly": "User Friendly options, separate entries by a ;",
"current_temperature_dp": "Current Temperature",
"target_temperature_dp": "Target Temperature",
"temperature_step": "Temperature Step (optional)",
@@ -144,6 +148,10 @@
"fan_speed_low": "Fan Low Speed Setting",
"fan_speed_medium": "Fan Medium Speed Setting",
"fan_speed_high": "Fan High Speed Setting",
"max_value": "Maximum Value",
"min_value": "Minimum Value",
"select_options": "Valid entries, separate entries by a ;",
"select_options_friendly": "User Friendly options, separate entries by a ;",
"current_temperature_dp": "Current Temperature",
"target_temperature_dp": "Target Temperature",
"temperature_step": "Temperature Step (optional)",

View File

@@ -1,6 +1,6 @@
{
"name": "Local Tuya",
"domains": ["climate", "cover", "fan", "light", "sensor", "switch"],
"domains": ["climate", "cover", "fan", "light", "number", "select", "sensor", "switch"],
"homeassistant": "0.116.0",
"iot_class": ["Local Push"]
}

13
info.md
View File

@@ -66,7 +66,18 @@ localtuya:
- platform: light
friendly_name: Device Light
id: 4
id: 4 # Usually 1 or 20
color_mode: 21 # Optional, usually 2 or 21, default: "none"
brightness: 22 # Optional, usually 3 or 22, default: "none"
color_temp: 23 # Optional, usually 4 or 23, default: "none"
color_temp_reverse: false # Optional, default: false
color: 24 # Optional, usually 5 (RGB_HSV) or 24 (HSV), default: "none"
brightness_lower: 29 # Optional, usually 0 or 29, default: 29
brightness_upper: 1000 # Optional, usually 255 or 1000, default: 1000
color_temp_min_kelvin: 2700 # Optional, default: 2700
color_temp_max_kelvin: 6500 # Optional, default: 6500
scene: 25 # Optional, usually 6 (RGB_HSV) or 25 (HSV), default: "none"
music_mode: False # Optional, some use internal mic, others, phone mic. Only internal mic is supported, default: "False"
- platform: sensor
friendly_name: Plug Voltage

View File

@@ -3,7 +3,7 @@ codespell==2.0.0
flake8==3.9.2
mypy==0.901
pydocstyle==6.1.1
cryptography==3.2
cryptography==3.3.2
pylint==2.8.2
pylint-strict-informational==0.1
homeassistant==2021.1.4
homeassistant==2021.7.1

View File

@@ -12,7 +12,7 @@ warn_incomplete_stub = true
warn_redundant_casts = true
warn_unused_configs = true
[mypy-homeassistant.block_async_io,homeassistant.bootstrap,homeassistant.components,homeassistant.config_entries,homeassistant.config,homeassistant.const,homeassistant.core,homeassistant.data_entry_flow,homeassistant.exceptions,homeassistant.__init__,homeassistant.loader,homeassistant.__main__,homeassistant.requirements,homeassistant.runner,homeassistant.setup,homeassistant.util,homeassistant.auth.*,homeassistant.components.automation.*,homeassistant.components.binary_sensor.*,homeassistant.components.calendar.*,homeassistant.components.cover.*,homeassistant.components.device_automation.*,homeassistant.components.frontend.*,homeassistant.components.geo_location.*,homeassistant.components.group.*,homeassistant.components.history.*,homeassistant.components.http.*,homeassistant.components.image_processing.*,homeassistant.components.integration.*,homeassistant.components.light.*,homeassistant.components.lock.*,homeassistant.components.mailbox.*,homeassistant.components.media_player.*,homeassistant.components.notify.*,homeassistant.components.persistent_notification.*,homeassistant.components.proximity.*,homeassistant.components.remote.*,homeassistant.components.scene.*,homeassistant.components.sensor.*,homeassistant.components.sun.*,homeassistant.components.switch.*,homeassistant.components.systemmonitor.*,homeassistant.components.tts.*,homeassistant.components.vacuum.*,homeassistant.components.water_heater.*,homeassistant.components.weather.*,homeassistant.components.websocket_api.*,homeassistant.components.zone.*,homeassistant.helpers.*,homeassistant.scripts.*,homeassistant.util.*]
[mypy-homeassistant.block_async_io,homeassistant.bootstrap,homeassistant.components,homeassistant.config_entries,homeassistant.config,homeassistant.const,homeassistant.core,homeassistant.data_entry_flow,homeassistant.exceptions,homeassistant.__init__,homeassistant.loader,homeassistant.__main__,homeassistant.requirements,homeassistant.runner,homeassistant.setup,homeassistant.util,homeassistant.auth.*,homeassistant.components.automation.*,homeassistant.components.binary_sensor.*,homeassistant.components.calendar.*,homeassistant.components.cover.*,homeassistant.components.device_automation.*,homeassistant.components.frontend.*,homeassistant.components.geo_location.*,homeassistant.components.group.*,homeassistant.components.history.*,homeassistant.components.http.*,homeassistant.components.image_processing.*,homeassistant.components.integration.*,homeassistant.components.light.*,homeassistant.components.lock.*,homeassistant.components.mailbox.*,homeassistant.components.media_player.*,homeassistant.components.notify.*,homeassistant.components.persistent_notification.*,homeassistant.components.proximity.*,homeassistant.components.remote.*,homeassistant.components.scene.*,homeassistant.components.sensor.*,homeassistant.components.sun.*,homeassistant.components.switch.*,homeassistant.components.systemmonitor.*,homeassistant.components.tts.*,homeassistant.components.vacuum.*,homeassistant.components.water_heater.*,homeassistant.components.weather.*,homeassistant.components.websocket_api.*,homeassistant.components.zone.*,homeassistant.helpers.*,homeassistant.scripts.*,homeassistant.util.*,homeassistant.components.select.*,homeassistant.components.number.*]
strict = true
ignore_errors = false
warn_unreachable = true