diff --git a/README.md b/README.md index a4ccf04..7d43da2 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,8 @@ localtuya: local_key: xxxxx friendly_name: Tuya Device protocol_version: "3.3" + scan_interval: # optional, only needed if energy monitoring values are not updating + seconds: 30 # Values less than 10 seconds may cause stability issues entities: - platform: binary_sensor friendly_name: Plug Status @@ -63,7 +65,16 @@ localtuya: - platform: fan friendly_name: Device Fan - id: 3 + id: 3 # dps for on/off state + fan_direction: 4 # Optional, dps for fan direction + fan_direction_fwd: forward # String for the forward direction + fan_direction_rev: reverse # String for the reverse direction + fan_ordered_list: low,medium,high,auto # Optional, If this is used it will not use the min and max integers. + fan_oscilating_control: 4 # Optional, dps for fan osciallation + fan_speed_control: 3 # Optional, if ordered list not used, dps for speed control + fan_speed_min: 1 # Optional, if ordered list not used, minimum integer for speed range + fan_speed_max: 10 # Optional, if ordered list not used, maximum integer for speed range + - platform: light friendly_name: Device Light @@ -71,6 +82,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 +91,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 @@ -112,9 +123,13 @@ select one of these, or manually input all the parameters. ![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. + +Setting the scan interval is optional, only needed if energy/power values are not updating frequently enough by default. Values less than 10 seconds may cause stability issues. + Once you press "Submit", the connection is tested to check that everything works. -![device](https://github.com/rospogrigio/localtuya-homeassistant/blob/master/img/2-device.png) +![image](https://user-images.githubusercontent.com/1082213/146664103-ac40319e-f934-4933-90cf-2beaff1e6bac.png) + Then, it's time to add the entities: this step will take place several times. First, select the entity type from the drop-down menu to set it up. After you have defined all the needed entities, leave the "Do not add more entities" checkbox checked: this will complete the procedure. @@ -140,6 +155,7 @@ You can obtain Energy monitoring (voltage, current) in two different ways: Note: Voltage and Consumption usually include the first decimal. You will need to scale the parament by 0.1 to get the correct values. 1) Access the voltage/current/current_consumption attributes of a switch, and define template sensors Note: these values are already divided by 10 for Voltage and Consumption +1) On some devices, you may find that the energy values are not updating frequently enough by default. If so, set the scan interval (see above) to an appropriate value. Settings below 10 seconds may cause stability issues, 30 seconds is recommended. ``` sensor: diff --git a/custom_components/localtuya/common.py b/custom_components/localtuya/common.py index 68b0d28..ea821a5 100644 --- a/custom_components/localtuya/common.py +++ b/custom_components/localtuya/common.py @@ -1,6 +1,7 @@ """Code shared between all platforms.""" import asyncio import logging +from datetime import timedelta from homeassistant.const import ( CONF_DEVICE_ID, @@ -9,8 +10,10 @@ from homeassistant.const import ( CONF_HOST, CONF_ID, CONF_PLATFORM, + CONF_SCAN_INTERVAL, ) from homeassistant.core import callback +from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, @@ -117,6 +120,7 @@ class TuyaDevice(pytuya.TuyaListener, pytuya.ContextualLogger): self._is_closing = False self._connect_task = None self._disconnect_task = None + self._unsub_interval = None self.set_logger(_LOGGER, config_entry[CONF_DEVICE_ID]) # This has to be done in case the device type is type_0d @@ -166,6 +170,16 @@ class TuyaDevice(pytuya.TuyaListener, pytuya.ContextualLogger): self._disconnect_task = async_dispatcher_connect( self._hass, signal, _new_entity_handler ) + + if ( + CONF_SCAN_INTERVAL in self._config_entry + and self._config_entry[CONF_SCAN_INTERVAL] > 0 + ): + self._unsub_interval = async_track_time_interval( + self._hass, + self._async_refresh, + timedelta(seconds=self._config_entry[CONF_SCAN_INTERVAL]), + ) except Exception: # pylint: disable=broad-except self.exception(f"Connect to {self._config_entry[CONF_HOST]} failed") if self._interface is not None: @@ -173,6 +187,10 @@ class TuyaDevice(pytuya.TuyaListener, pytuya.ContextualLogger): self._interface = None self._connect_task = None + async def _async_refresh(self, _now): + if self._interface is not None: + await self._interface.update_dps() + async def close(self): """Close connection and stop re-connect loop.""" self._is_closing = True @@ -223,7 +241,9 @@ class TuyaDevice(pytuya.TuyaListener, pytuya.ContextualLogger): """Device disconnected.""" signal = f"localtuya_{self._config_entry[CONF_DEVICE_ID]}" async_dispatcher_send(self._hass, signal, None) - + if self._unsub_interval is not None: + self._unsub_interval() + self._unsub_interval = None self._interface = None self.debug("Disconnected - waiting for discovery broadcast") @@ -253,13 +273,13 @@ class LocalTuyaEntity(RestoreEntity, pytuya.ContextualLogger): def _update_handler(status): """Update entity state when status was updated.""" - if status is not None: - self._status = status - self.status_updated() - else: - self._status = {} - - self.schedule_update_ha_state() + if status is None: + status = {} + if self._status != status: + self._status = status.copy() + if status: + self.status_updated() + self.schedule_update_ha_state() signal = f"localtuya_{self._config_entry.data[CONF_DEVICE_ID]}" diff --git a/custom_components/localtuya/config_flow.py b/custom_components/localtuya/config_flow.py index 731b60a..03bf80d 100644 --- a/custom_components/localtuya/config_flow.py +++ b/custom_components/localtuya/config_flow.py @@ -14,6 +14,7 @@ from homeassistant.const import ( CONF_HOST, CONF_ID, CONF_PLATFORM, + CONF_SCAN_INTERVAL, ) from homeassistant.core import callback @@ -44,6 +45,7 @@ BASIC_INFO_SCHEMA = vol.Schema( vol.Required(CONF_HOST): str, vol.Required(CONF_DEVICE_ID): str, vol.Required(CONF_PROTOCOL_VERSION, default="3.3"): vol.In(["3.1", "3.3"]), + vol.Optional(CONF_SCAN_INTERVAL): int, } ) @@ -55,6 +57,7 @@ DEVICE_SCHEMA = vol.Schema( vol.Required(CONF_LOCAL_KEY): cv.string, vol.Required(CONF_FRIENDLY_NAME): cv.string, vol.Required(CONF_PROTOCOL_VERSION, default="3.3"): vol.In(["3.1", "3.3"]), + vol.Optional(CONF_SCAN_INTERVAL): int, } ) @@ -90,6 +93,7 @@ def options_schema(entities): vol.Required(CONF_HOST): str, vol.Required(CONF_LOCAL_KEY): str, vol.Required(CONF_PROTOCOL_VERSION, default="3.3"): vol.In(["3.1", "3.3"]), + vol.Optional(CONF_SCAN_INTERVAL): int, vol.Required( CONF_ENTITIES, description={"suggested_value": entity_names} ): cv.multi_select(entity_names), diff --git a/custom_components/localtuya/const.py b/custom_components/localtuya/const.py index faf6f35..07ce595 100644 --- a/custom_components/localtuya/const.py +++ b/custom_components/localtuya/const.py @@ -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 @@ -34,9 +35,12 @@ CONF_SPAN_TIME = "span_time" # fan CONF_FAN_SPEED_CONTROL = "fan_speed_control" CONF_FAN_OSCILLATING_CONTROL = "fan_oscillating_control" -CONF_FAN_SPEED_LOW = "fan_speed_low" -CONF_FAN_SPEED_MEDIUM = "fan_speed_medium" -CONF_FAN_SPEED_HIGH = "fan_speed_high" +CONF_FAN_SPEED_MIN = "fan_speed_min" +CONF_FAN_SPEED_MAX = "fan_speed_max" +CONF_FAN_ORDERED_LIST = "fan_speed_ordered_list" +CONF_FAN_DIRECTION = "fan_direction" +CONF_FAN_DIRECTION_FWD = "fan_direction_forward" +CONF_FAN_DIRECTION_REV = "fan_direction_reverse" # sensor CONF_SCALING = "scaling" @@ -65,6 +69,16 @@ DATA_DISCOVERY = "discovery" DOMAIN = "localtuya" # Platforms in this list must support config flows -PLATFORMS = ["binary_sensor", "cover", "fan", "light", "sensor", "switch", "vacuum"] +PLATFORMS = [ + "binary_sensor", + "cover", + "fan", + "light", + "number", + "select", + "sensor", + "switch", + "vacuum" +] TUYA_DEVICE = "tuya_device" diff --git a/custom_components/localtuya/fan.py b/custom_components/localtuya/fan.py index 834e199..d2b4583 100644 --- a/custom_components/localtuya/fan.py +++ b/custom_components/localtuya/fan.py @@ -1,26 +1,37 @@ """Platform to locally control Tuya-based fan devices.""" import logging +import math from functools import partial +import homeassistant.helpers.config_validation as cv import voluptuous as vol from homeassistant.components.fan import ( + DIRECTION_FORWARD, + DIRECTION_REVERSE, DOMAIN, - SPEED_HIGH, - SPEED_LOW, - SPEED_MEDIUM, - SPEED_OFF, + SUPPORT_DIRECTION, SUPPORT_OSCILLATE, SUPPORT_SET_SPEED, FanEntity, ) +from homeassistant.util.percentage import ( + int_states_in_range, + ordered_list_item_to_percentage, + percentage_to_ordered_list_item, + percentage_to_ranged_value, + ranged_value_to_percentage, +) from .common import LocalTuyaEntity, async_setup_entry from .const import ( + CONF_FAN_DIRECTION, + CONF_FAN_DIRECTION_FWD, + CONF_FAN_DIRECTION_REV, + CONF_FAN_ORDERED_LIST, CONF_FAN_OSCILLATING_CONTROL, CONF_FAN_SPEED_CONTROL, - CONF_FAN_SPEED_HIGH, - CONF_FAN_SPEED_LOW, - CONF_FAN_SPEED_MEDIUM, + CONF_FAN_SPEED_MAX, + CONF_FAN_SPEED_MIN, ) _LOGGER = logging.getLogger(__name__) @@ -31,15 +42,12 @@ def flow_schema(dps): return { vol.Optional(CONF_FAN_SPEED_CONTROL): vol.In(dps), vol.Optional(CONF_FAN_OSCILLATING_CONTROL): vol.In(dps), - vol.Optional(CONF_FAN_SPEED_LOW, default=SPEED_LOW): vol.In( - [SPEED_LOW, "1", "2", "small"] - ), - vol.Optional(CONF_FAN_SPEED_MEDIUM, default=SPEED_MEDIUM): vol.In( - [SPEED_MEDIUM, "mid", "2", "3"] - ), - vol.Optional(CONF_FAN_SPEED_HIGH, default=SPEED_HIGH): vol.In( - [SPEED_HIGH, "auto", "3", "4", "large", "big"] - ), + vol.Optional(CONF_FAN_DIRECTION): vol.In(dps), + vol.Optional(CONF_FAN_DIRECTION_FWD, default="forward"): cv.string, + vol.Optional(CONF_FAN_DIRECTION_REV, default="reverse"): cv.string, + vol.Optional(CONF_FAN_SPEED_MIN, default=1): cv.positive_int, + vol.Optional(CONF_FAN_SPEED_MAX, default=9): cv.positive_int, + vol.Optional(CONF_FAN_ORDERED_LIST, default="disabled"): cv.string, } @@ -56,101 +64,192 @@ class LocaltuyaFan(LocalTuyaEntity, FanEntity): """Initialize the entity.""" super().__init__(device, config_entry, fanid, _LOGGER, **kwargs) self._is_on = False - self._speed = None self._oscillating = None + self._direction = None + self._percentage = None + self._speed_range = ( + self._config.get(CONF_FAN_SPEED_MIN), + self._config.get(CONF_FAN_SPEED_MAX), + ) + self._ordered_list = self._config.get(CONF_FAN_ORDERED_LIST).split(",") + self._ordered_list_mode = None + + if isinstance(self._ordered_list, list) and len(self._ordered_list) > 1: + self._use_ordered_list = True + _LOGGER.debug( + "Fan _use_ordered_list: %s > %s", + self._use_ordered_list, + self._ordered_list, + ) + else: + self._use_ordered_list = False + _LOGGER.debug("Fan _use_ordered_list: %s", self._use_ordered_list) @property def oscillating(self): """Return current oscillating status.""" return self._oscillating + @property + def current_direction(self): + """Return the current direction of the fan.""" + return self._direction + @property def is_on(self): """Check if Tuya fan is on.""" return self._is_on @property - def speed(self) -> str: - """Return the current speed.""" - return self._speed + def percentage(self): + """Return the current percentage.""" + return self._percentage - @property - def speed_list(self) -> list: - """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.""" + _LOGGER.debug("Fan async_turn_on") await self._device.set_dp(True, self._dp_id) - if speed is not None: - await self.async_set_speed(speed) + if percentage is not None: + await self.async_set_percentage(percentage) else: self.schedule_update_ha_state() async def async_turn_off(self, **kwargs) -> None: """Turn off the entity.""" + _LOGGER.debug("Fan async_turn_off") + await self._device.set_dp(False, self._dp_id) self.schedule_update_ha_state() - async def async_set_speed(self, speed: str) -> None: + async def async_set_percentage(self, percentage): """Set the speed of the fan.""" - mapping = { - SPEED_LOW: self._config.get(CONF_FAN_SPEED_LOW), - SPEED_MEDIUM: self._config.get(CONF_FAN_SPEED_MEDIUM), - SPEED_HIGH: self._config.get(CONF_FAN_SPEED_HIGH), - } + _LOGGER.debug("Fan async_set_percentage: %s", percentage) - if speed == SPEED_OFF: - await self._device.set_dp(False, self._dp_id) - else: - await self._device.set_dp( - mapping.get(speed), self._config.get(CONF_FAN_SPEED_CONTROL) - ) + if percentage is not None: + if percentage == 0: + return await self.async_turn_off() + if not self.is_on: + await self.async_turn_on() + if self._use_ordered_list: + await self._device.set_dp( + str( + percentage_to_ordered_list_item(self._ordered_list, percentage) + ), + self._config.get(CONF_FAN_SPEED_CONTROL), + ) + _LOGGER.debug( + "Fan async_set_percentage: %s > %s", + percentage, + percentage_to_ordered_list_item(self._ordered_list, percentage), + ) - self.schedule_update_ha_state() + else: + await self._device.set_dp( + str( + math.ceil( + percentage_to_ranged_value(self._speed_range, percentage) + ) + ), + self._config.get(CONF_FAN_SPEED_CONTROL), + ) + _LOGGER.debug( + "Fan async_set_percentage: %s > %s", + percentage, + percentage_to_ranged_value(self._speed_range, percentage), + ) + self.schedule_update_ha_state() async def async_oscillate(self, oscillating: bool) -> None: """Set oscillation.""" + _LOGGER.debug("Fan async_oscillate: %s", oscillating) await self._device.set_dp( oscillating, self._config.get(CONF_FAN_OSCILLATING_CONTROL) ) self.schedule_update_ha_state() + async def async_set_direction(self, direction): + """Set the direction of the fan.""" + _LOGGER.debug("Fan async_set_direction: %s", direction) + + if direction == DIRECTION_FORWARD: + value = self._config.get(CONF_FAN_DIRECTION_FWD) + + if direction == DIRECTION_REVERSE: + value = self._config.get(CONF_FAN_DIRECTION_REV) + await self._device.set_dp(value, self._config.get(CONF_FAN_DIRECTION)) + self.schedule_update_ha_state() + @property def supported_features(self) -> int: """Flag supported features.""" - supports = 0 + features = 0 if self.has_config(CONF_FAN_OSCILLATING_CONTROL): - supports |= SUPPORT_OSCILLATE - if self.has_config(CONF_FAN_SPEED_CONTROL): - supports |= SUPPORT_SET_SPEED + features |= SUPPORT_OSCILLATE - return supports + if self.has_config(CONF_FAN_SPEED_CONTROL): + features |= SUPPORT_SET_SPEED + + if self.has_config(CONF_FAN_DIRECTION): + features |= SUPPORT_DIRECTION + + return features + + @property + def speed_count(self) -> int: + """Speed count for the fan.""" + speed_count = int_states_in_range(self._speed_range) + _LOGGER.debug("Fan speed_count: %s", speed_count) + return speed_count def status_updated(self): """Get state of Tuya fan.""" - mappings = { - self._config.get(CONF_FAN_SPEED_LOW): SPEED_LOW, - self._config.get(CONF_FAN_SPEED_MEDIUM): SPEED_MEDIUM, - self._config.get(CONF_FAN_SPEED_HIGH): SPEED_HIGH, - } - self._is_on = self.dps(self._dp_id) - if self.has_config(CONF_FAN_SPEED_CONTROL): - self._speed = mappings.get(self.dps_conf(CONF_FAN_SPEED_CONTROL)) - if self.speed is None: - self.warning( - "%s/%s: Ignoring unknown fan controller state: %s", - self.name, - self.entity_id, - self.dps_conf(CONF_FAN_SPEED_CONTROL), + current_speed = self.dps_conf(CONF_FAN_SPEED_CONTROL) + if self._use_ordered_list: + _LOGGER.debug( + "Fan current_speed ordered_list_item_to_percentage: %s from %s", + current_speed, + self._ordered_list, + ) + if current_speed is not None: + self._percentage = ordered_list_item_to_percentage( + self._ordered_list, current_speed ) - self._speed = None + + else: + _LOGGER.debug( + "Fan current_speed ranged_value_to_percentage: %s from %s", + current_speed, + self._speed_range, + ) + if current_speed is not None: + self._percentage = ranged_value_to_percentage( + self._speed_range, int(current_speed) + ) + + _LOGGER.debug("Fan current_percentage: %s", self._percentage) if self.has_config(CONF_FAN_OSCILLATING_CONTROL): self._oscillating = self.dps_conf(CONF_FAN_OSCILLATING_CONTROL) + _LOGGER.debug("Fan current_oscillating : %s", self._oscillating) + + if self.has_config(CONF_FAN_DIRECTION): + value = self.dps_conf(CONF_FAN_DIRECTION) + if value is not None: + if value == self._config.get(CONF_FAN_DIRECTION_FWD): + self._direction = DIRECTION_FORWARD + + if value == self._config.get(CONF_FAN_DIRECTION_REV): + self._direction = DIRECTION_REVERSE + _LOGGER.debug("Fan current_direction : %s > %s", value, self._direction) async_setup_entry = partial(async_setup_entry, DOMAIN, LocaltuyaFan, flow_schema) diff --git a/custom_components/localtuya/light.py b/custom_components/localtuya/light.py index 49fade8..e99414e 100644 --- a/custom_components/localtuya/light.py +++ b/custom_components/localtuya/light.py @@ -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 diff --git a/custom_components/localtuya/number.py b/custom_components/localtuya/number.py new file mode 100644 index 0000000..596eb01 --- /dev/null +++ b/custom_components/localtuya/number.py @@ -0,0 +1,84 @@ +"""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) diff --git a/custom_components/localtuya/pytuya/__init__.py b/custom_components/localtuya/pytuya/__init__.py index 789091c..b7645ec 100644 --- a/custom_components/localtuya/pytuya/__init__.py +++ b/custom_components/localtuya/pytuya/__init__.py @@ -21,6 +21,7 @@ Functions json = status() # returns json payload set_version(version) # 3.1 [default] or 3.3 detect_available_dps() # returns a list of available dps provided by the device + update_dps(dps) # sends update dps command add_dps_to_request(dp_index) # adds dp_index to the list of dps used by the # device (to be queried in the payload) set_dp(on, dp_index) # Set value of any dps index. @@ -61,6 +62,7 @@ TuyaMessage = namedtuple("TuyaMessage", "seqno cmd retcode payload crc") SET = "set" STATUS = "status" HEARTBEAT = "heartbeat" +UPDATEDPS = "updatedps" # Request refresh of DPS PROTOCOL_VERSION_BYTES_31 = b"3.1" PROTOCOL_VERSION_BYTES_33 = b"3.3" @@ -76,6 +78,9 @@ SUFFIX_VALUE = 0x0000AA55 HEARTBEAT_INTERVAL = 10 +# DPS that are known to be safe to use with update_dps (0x12) command +UPDATE_DPS_WHITELIST = [18, 19, 20] # Socket (Wi-Fi) + # This is intended to match requests.json payload at # https://github.com/codetheweb/tuyapi : # type_0a devices require the 0a command as the status request @@ -90,11 +95,13 @@ PAYLOAD_DICT = { STATUS: {"hexByte": 0x0A, "command": {"gwId": "", "devId": ""}}, SET: {"hexByte": 0x07, "command": {"devId": "", "uid": "", "t": ""}}, HEARTBEAT: {"hexByte": 0x09, "command": {}}, + UPDATEDPS: {"hexByte": 0x12, "command": {"dpId": [18, 19, 20]}}, }, "type_0d": { STATUS: {"hexByte": 0x0D, "command": {"devId": "", "uid": "", "t": ""}}, SET: {"hexByte": 0x07, "command": {"devId": "", "uid": "", "t": ""}}, HEARTBEAT: {"hexByte": 0x09, "command": {}}, + UPDATEDPS: {"hexByte": 0x12, "command": {"dpId": [18, 19, 20]}}, }, } @@ -292,6 +299,8 @@ class MessageDispatcher(ContextualLogger): sem = self.listeners[self.HEARTBEAT_SEQNO] self.listeners[self.HEARTBEAT_SEQNO] = msg sem.release() + elif msg.cmd == 0x12: + self.debug("Got normal updatedps response") elif msg.cmd == 0x08: self.debug("Got status update") self.listener(msg) @@ -478,6 +487,26 @@ class TuyaProtocol(asyncio.Protocol, ContextualLogger): """Send a heartbeat message.""" return await self.exchange(HEARTBEAT) + async def update_dps(self, dps=None): + """ + Request device to update index. + + Args: + dps([int]): list of dps to update, default=detected&whitelisted + """ + if self.version == 3.3: + if dps is None: + if not self.dps_cache: + await self.detect_available_dps() + if self.dps_cache: + dps = [int(dp) for dp in self.dps_cache] + # filter non whitelisted dps + dps = list(set(dps).intersection(set(UPDATE_DPS_WHITELIST))) + self.debug("updatedps() entry (dps %s, dps_cache %s)", dps, self.dps_cache) + payload = self._generate_payload(UPDATEDPS, dps) + self.transport.write(payload) + return True + async def set_dp(self, value, dp_index): """ Set value (may be any type: bool, int or string) of any dps index. @@ -582,7 +611,10 @@ class TuyaProtocol(asyncio.Protocol, ContextualLogger): json_data["t"] = str(int(time.time())) if data is not None: - json_data["dps"] = data + if "dpId" in json_data: + json_data["dpId"] = data + else: + json_data["dps"] = data elif command_hb == 0x0D: json_data["dps"] = self.dps_to_request @@ -591,7 +623,7 @@ class TuyaProtocol(asyncio.Protocol, ContextualLogger): if self.version == 3.3: payload = self.cipher.encrypt(payload, False) - if command_hb != 0x0A: + if command_hb not in [0x0A, 0x12]: # add the 3.3 header payload = PROTOCOL_33_HEADER + payload elif command == SET: diff --git a/custom_components/localtuya/select.py b/custom_components/localtuya/select.py new file mode 100644 index 0000000..29d11c9 --- /dev/null +++ b/custom_components/localtuya/select.py @@ -0,0 +1,100 @@ +"""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) diff --git a/custom_components/localtuya/strings.json b/custom_components/localtuya/strings.json index 898b99d..b8bedc8 100644 --- a/custom_components/localtuya/strings.json +++ b/custom_components/localtuya/strings.json @@ -20,6 +20,7 @@ "device_id": "Device ID", "local_key": "Local key", "protocol_version": "Protocol Version", + "scan_interval": "Scan interval (seconds, only when not updating automatically)", "device_type": "Device type" } }, @@ -39,4 +40,4 @@ } }, "title": "LocalTuya" -} \ No newline at end of file +} diff --git a/custom_components/localtuya/switch.py b/custom_components/localtuya/switch.py index d3e08f5..f43d910 100644 --- a/custom_components/localtuya/switch.py +++ b/custom_components/localtuya/switch.py @@ -48,7 +48,7 @@ class LocaltuyaSwitch(LocalTuyaEntity, SwitchEntity): return self._state @property - def device_state_attributes(self): + def extra_state_attributes(self): """Return device state attributes.""" attrs = {} if self.has_config(CONF_CURRENT): diff --git a/custom_components/localtuya/translations/en.json b/custom_components/localtuya/translations/en.json index 0513276..15e7089 100644 --- a/custom_components/localtuya/translations/en.json +++ b/custom_components/localtuya/translations/en.json @@ -29,7 +29,8 @@ "host": "Host", "device_id": "Device ID", "local_key": "Local key", - "protocol_version": "Protocol Version" + "protocol_version": "Protocol Version", + "scan_interval": "Scan interval (seconds, only when not updating automatically)" } }, "pick_entity_type": { @@ -81,17 +82,21 @@ "brightness_lower": "Brightness Lower Value", "brightness_upper": "Brightness Upper Value", "color_temp": "Color Temperature", + "color_temp_reverse": "Color Temperature Reverse", "color": "Color", "color_mode": "Color Mode", "color_temp_min_kelvin": "Minimum Color Temperature in K", "color_temp_max_kelvin": "Maximum Color Temperature in K", "music_mode": "Music mode available", "scene": "Scene", - "fan_speed_control": "Fan Speed Control", - "fan_oscillating_control": "Fan Oscillating Control", - "fan_speed_low": "Fan Low Speed Setting", - "fan_speed_medium": "Fan Medium Speed Setting", - "fan_speed_high": "Fan High Speed Setting" + "fan_speed_control": "Fan Speed Control dps", + "fan_oscillating_control": "Fan Oscillating Control dps", + "fan_speed_min": "minimum fan speed integer", + "fan_speed_max": "maximum fan speed integer", + "fan_speed_ordered_list": "Fan speed modes list (overrides speed min/max)", + "fan_direction":"fan direction dps", + "fan_direction_forward": "forward dps string", + "fan_direction_reverse": "reverse dps string" } } } @@ -106,6 +111,7 @@ "host": "Host", "local_key": "Local key", "protocol_version": "Protocol Version", + "scan_interval": "Scan interval (seconds, only when not updating automatically)", "entities": "Entities (uncheck an entity to remove it)" } }, @@ -150,17 +156,21 @@ "brightness_lower": "Brightness Lower Value", "brightness_upper": "Brightness Upper Value", "color_temp": "Color Temperature", + "color_temp_reverse": "Color Temperature Reverse", "color": "Color", "color_mode": "Color Mode", "color_temp_min_kelvin": "Minimum Color Temperature in K", "color_temp_max_kelvin": "Maximum Color Temperature in K", "music_mode": "Music mode available", "scene": "Scene", - "fan_speed_control": "Fan Speed Control", - "fan_oscillating_control": "Fan Oscillating Control", - "fan_speed_low": "Fan Low Speed Setting", - "fan_speed_medium": "Fan Medium Speed Setting", - "fan_speed_high": "Fan High Speed Setting" + "fan_speed_control": "Fan Speed Control dps", + "fan_oscillating_control": "Fan Oscillating Control dps", + "fan_speed_min": "minimum fan speed integer", + "fan_speed_max": "maximum fan speed integer", + "fan_speed_ordered_list": "Fan speed modes list (overrides speed min/max)", + "fan_direction":"fan direction dps", + "fan_direction_forward": "forward dps string", + "fan_direction_reverse": "reverse dps string" } }, "yaml_import": { diff --git a/hacs.json b/hacs.json index 4b6ec05..3c5a41a 100644 --- a/hacs.json +++ b/hacs.json @@ -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"] } diff --git a/info.md b/info.md index 3c69026..0489319 100644 --- a/info.md +++ b/info.md @@ -43,6 +43,8 @@ localtuya: local_key: xxxxx friendly_name: Tuya Device protocol_version: "3.3" + scan_interval: # optional, only needed if energy monitoring values are not updating + seconds: 30 # Values less than 10 seconds may cause stability issues entities: - platform: binary_sensor friendly_name: Plug Status @@ -66,7 +68,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 @@ -98,9 +111,12 @@ select one of these, or manually input all the parameters. ![discovery](https://github.com/rospogrigio/localtuya-homeassistant/blob/master/img/1-discovery.png) If you have selected one entry, you just have to input the Friendly Name of the Device, and the localKey. -Once you press "Submit", the connection will be tested to check that everything works, in order to proceed. -![device](https://github.com/rospogrigio/localtuya-homeassistant/blob/master/img/2-device.png) +Setting the scan interval is optional, only needed if energy/power values are not updating frequently enough by default. Values less than 10 seconds may cause stability issues. + +Once you press "Submit", the connection is tested to check that everything works. + +![image](https://user-images.githubusercontent.com/1082213/146664103-ac40319e-f934-4933-90cf-2beaff1e6bac.png) Then, it's time to add the entities: this step will take place several times. Select the entity type from the drop-down menu to set it up. After you have defined all the needed entities leave the "Do not add more entities" checkbox checked: this will complete the procedure. @@ -122,7 +138,8 @@ After all the entities have been configured, the procedure is complete, and the Energy monitoring (voltage, current...) values can be obtained in two different ways: 1) creating individual sensors, each one with the desired name. Note: Voltage and Consumption usually include the first decimal, so 0.1 as "scaling" parameter shall be used in order to get the correct values. -2) accessing the voltage/current/current_consumption attributes of a switch, and then defining template sensors like this (please note that in this case the values are already divided by 10 for Voltage and Consumption): +2) accessing the voltage/current/current_consumption attributes of a switch, and then defining template sensors like this (please note that in this case the values are already divided by 10 for Voltage and Consumption) +3) On some devices, you may find that the energy values are not updating frequently enough by default. If so, set the scan interval (see above) to an appropriate value. Settings below 10 seconds may cause stability issues, 30 seconds is recommended. ``` sensor: diff --git a/requirements_test.txt b/requirements_test.txt index df62e77..a4e209a 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -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 diff --git a/setup.cfg b/setup.cfg index 2d94fd3..562dc77 100644 --- a/setup.cfg +++ b/setup.cfg @@ -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