Merge branch 'master' into master

This commit is contained in:
deese
2022-09-05 23:00:24 +02:00
committed by GitHub
16 changed files with 427 additions and 57 deletions

View File

@@ -92,9 +92,13 @@ If you have selected one entry, you only need to input the device's Friendly Nam
Setting the scan interval is optional, it is only needed if energy/power values are not updating frequently enough by default. Values less than 10 seconds may cause stability issues. Setting the scan interval is optional, it is only needed if energy/power values are not updating frequently enough by default. Values less than 10 seconds may cause stability issues.
Setting the 'Manual DPS To Add' is optional, it is only needed if the device doesn't advertise the DPS correctly until the entity has been properly initiailised. This setting can often be avoided by first connecting/initialising the device with the Tuya App, then closing the app and then adding the device in the integration.
Setting the 'DPIDs to send in RESET command' is optional. It is used when a device doesn't respond to any Tuya commands after a power cycle, but can be connected to (zombie state). The DPids will vary between devices, but typically "18,19,20" is used (and will be the default if none specified). If the wrong entries are added here, then the device may not come out of the zombie state. Typically only sensor DPIDs entered here.
Once you press "Submit", the connection is tested to check that everything works. 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) ![image](https://github.com/rospogrigio/localtuya-homeassistant/blob/master/img/2-device.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. 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.

View File

@@ -53,6 +53,8 @@ class LocaltuyaBinarySensor(LocalTuyaEntity, BinarySensorEntity):
def status_updated(self): def status_updated(self):
"""Device status was updated.""" """Device status was updated."""
super().status_updated()
state = str(self.dps(self._dp_id)).lower() state = str(self.dps(self._dp_id)).lower()
if state == self._config[CONF_STATE_ON].lower(): if state == self._config[CONF_STATE_ON].lower():
self._is_on = True self._is_on = True
@@ -63,6 +65,11 @@ class LocaltuyaBinarySensor(LocalTuyaEntity, BinarySensorEntity):
"State for entity %s did not match state patterns", self.entity_id "State for entity %s did not match state patterns", self.entity_id
) )
# No need to restore state for a sensor
async def restore_state_when_connected(self):
"""Do nothing for a sensor."""
return
async_setup_entry = partial( async_setup_entry = partial(
async_setup_entry, DOMAIN, LocaltuyaBinarySensor, flow_schema async_setup_entry, DOMAIN, LocaltuyaBinarySensor, flow_schema

View File

@@ -13,6 +13,7 @@ from homeassistant.const import (
CONF_ID, CONF_ID,
CONF_PLATFORM, CONF_PLATFORM,
CONF_SCAN_INTERVAL, CONF_SCAN_INTERVAL,
STATE_UNKNOWN,
) )
from homeassistant.core import callback from homeassistant.core import callback
from homeassistant.helpers.dispatcher import ( from homeassistant.helpers.dispatcher import (
@@ -31,6 +32,10 @@ from .const import (
DATA_CLOUD, DATA_CLOUD,
DOMAIN, DOMAIN,
TUYA_DEVICES, TUYA_DEVICES,
CONF_DEFAULT_VALUE,
ATTR_STATE,
CONF_RESTORE_ON_RECONNECT,
CONF_RESET_DPIDS,
) )
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -91,6 +96,8 @@ async def async_setup_entry(
entity_config[CONF_ID], entity_config[CONF_ID],
) )
) )
# Once the entities have been created, add to the TuyaDevice instance
tuyainterface.add_entities(entities)
async_add_entities(entities) async_add_entities(entities)
@@ -135,13 +142,31 @@ class TuyaDevice(pytuya.TuyaListener, pytuya.ContextualLogger):
self._connect_task = None self._connect_task = None
self._disconnect_task = None self._disconnect_task = None
self._unsub_interval = None self._unsub_interval = None
self._entities = []
self._local_key = self._dev_config_entry[CONF_LOCAL_KEY] self._local_key = self._dev_config_entry[CONF_LOCAL_KEY]
self._default_reset_dpids = None
if CONF_RESET_DPIDS in self._dev_config_entry:
reset_ids_str = self._dev_config_entry[CONF_RESET_DPIDS].split(",")
self._default_reset_dpids = []
for reset_id in reset_ids_str:
self._default_reset_dpids.append(int(reset_id.strip()))
self.set_logger(_LOGGER, self._dev_config_entry[CONF_DEVICE_ID]) self.set_logger(_LOGGER, self._dev_config_entry[CONF_DEVICE_ID])
# This has to be done in case the device type is type_0d # This has to be done in case the device type is type_0d
for entity in self._dev_config_entry[CONF_ENTITIES]: for entity in self._dev_config_entry[CONF_ENTITIES]:
self.dps_to_request[entity[CONF_ID]] = None self.dps_to_request[entity[CONF_ID]] = None
def add_entities(self, entities):
"""Set the entities associated with this device."""
self._entities.extend(entities)
@property
def is_connecting(self):
"""Return whether device is currently connecting."""
return self._connect_task is not None
@property @property
def connected(self): def connected(self):
"""Return if connected to device.""" """Return if connected to device."""
@@ -165,13 +190,64 @@ class TuyaDevice(pytuya.TuyaListener, pytuya.ContextualLogger):
self, self,
) )
self._interface.add_dps_to_request(self.dps_to_request) self._interface.add_dps_to_request(self.dps_to_request)
except Exception: # pylint: disable=broad-except
self.exception(f"Connect to {self._dev_config_entry[CONF_HOST]} failed")
if self._interface is not None:
await self._interface.close()
self._interface = None
self.debug("Retrieving initial state") if self._interface is not None:
status = await self._interface.status() try:
if status is None: self.debug("Retrieving initial state")
raise Exception("Failed to retrieve status") status = await self._interface.status()
if status is None:
raise Exception("Failed to retrieve status")
self.status_updated(status) self._interface.start_heartbeat()
self.status_updated(status)
except Exception as ex: # pylint: disable=broad-except
try:
self.debug(
"Initial state update failed, trying reset command "
+ "for DP IDs: %s",
self._default_reset_dpids,
)
await self._interface.reset(self._default_reset_dpids)
self.debug("Update completed, retrying initial state")
status = await self._interface.status()
if status is None or not status:
raise Exception("Failed to retrieve status") from ex
self._interface.start_heartbeat()
self.status_updated(status)
except UnicodeDecodeError as e: # pylint: disable=broad-except
self.exception(
f"Connect to {self._dev_config_entry[CONF_HOST]} failed: %s",
type(e),
)
if self._interface is not None:
await self._interface.close()
self._interface = None
except Exception as e: # pylint: disable=broad-except
self.exception(
f"Connect to {self._dev_config_entry[CONF_HOST]} failed"
)
if "json.decode" in str(type(e)):
await self.update_local_key()
if self._interface is not None:
await self._interface.close()
self._interface = None
if self._interface is not None:
# Attempt to restore status for all entities that need to first set
# the DPS value before the device will respond with status.
for entity in self._entities:
await entity.restore_state_when_connected()
def _new_entity_handler(entity_id): def _new_entity_handler(entity_id):
self.debug( self.debug(
@@ -195,22 +271,7 @@ class TuyaDevice(pytuya.TuyaListener, pytuya.ContextualLogger):
self._async_refresh, self._async_refresh,
timedelta(seconds=self._dev_config_entry[CONF_SCAN_INTERVAL]), timedelta(seconds=self._dev_config_entry[CONF_SCAN_INTERVAL]),
) )
except UnicodeDecodeError as e: # pylint: disable=broad-except
self.exception(
f"Connect to {self._dev_config_entry[CONF_HOST]} failed: %s", type(e)
)
if self._interface is not None:
await self._interface.close()
self._interface = None
except Exception as e: # pylint: disable=broad-except
self.exception(f"Connect to {self._dev_config_entry[CONF_HOST]} failed")
if "json.decode" in str(type(e)):
await self.update_local_key()
if self._interface is not None:
await self._interface.close()
self._interface = None
self._connect_task = None self._connect_task = None
async def update_local_key(self): async def update_local_key(self):
@@ -254,7 +315,7 @@ class TuyaDevice(pytuya.TuyaListener, pytuya.ContextualLogger):
try: try:
await self._interface.set_dp(state, dp_index) await self._interface.set_dp(state, dp_index)
except Exception: # pylint: disable=broad-except except Exception: # pylint: disable=broad-except
self.exception("Failed to set DP %d to %d", dp_index, state) self.exception("Failed to set DP %d to %s", dp_index, str(state))
else: else:
self.error( self.error(
"Not connected to device %s", self._dev_config_entry[CONF_FRIENDLY_NAME] "Not connected to device %s", self._dev_config_entry[CONF_FRIENDLY_NAME]
@@ -305,6 +366,17 @@ class LocalTuyaEntity(RestoreEntity, pytuya.ContextualLogger):
self._config = get_entity_config(config_entry, dp_id) self._config = get_entity_config(config_entry, dp_id)
self._dp_id = dp_id self._dp_id = dp_id
self._status = {} self._status = {}
self._state = None
self._last_state = None
# Default value is available to be provided by Platform entities if required
self._default_value = self._config.get(CONF_DEFAULT_VALUE)
""" Restore on connect setting is available to be provided by Platform entities
if required"""
self._restore_on_reconnect = (
self._config.get(CONF_RESTORE_ON_RECONNECT) or False
)
self.set_logger(logger, self._dev_config_entry[CONF_DEVICE_ID]) self.set_logger(logger, self._dev_config_entry[CONF_DEVICE_ID])
async def async_added_to_hass(self): async def async_added_to_hass(self):
@@ -325,6 +397,8 @@ class LocalTuyaEntity(RestoreEntity, pytuya.ContextualLogger):
self._status = status.copy() self._status = status.copy()
if status: if status:
self.status_updated() self.status_updated()
# Update HA
self.schedule_update_ha_state() self.schedule_update_ha_state()
signal = f"localtuya_{self._dev_config_entry[CONF_DEVICE_ID]}" signal = f"localtuya_{self._dev_config_entry[CONF_DEVICE_ID]}"
@@ -336,6 +410,22 @@ class LocalTuyaEntity(RestoreEntity, pytuya.ContextualLogger):
signal = f"localtuya_entity_{self._dev_config_entry[CONF_DEVICE_ID]}" signal = f"localtuya_entity_{self._dev_config_entry[CONF_DEVICE_ID]}"
async_dispatcher_send(self.hass, signal, self.entity_id) async_dispatcher_send(self.hass, signal, self.entity_id)
@property
def extra_state_attributes(self):
"""Return entity specific state attributes to be saved.
These attributes are then available for restore when the
entity is restored at startup.
"""
attributes = {}
if self._state is not None:
attributes[ATTR_STATE] = self._state
elif self._last_state is not None:
attributes[ATTR_STATE] = self._last_state
self.debug("Entity %s - Additional attributes: %s", self.name, attributes)
return attributes
@property @property
def device_info(self): def device_info(self):
"""Return device information for the device registry.""" """Return device information for the device registry."""
@@ -408,9 +498,89 @@ class LocalTuyaEntity(RestoreEntity, pytuya.ContextualLogger):
Override in subclasses and update entity specific state. Override in subclasses and update entity specific state.
""" """
state = self.dps(self._dp_id)
self._state = state
# Keep record in last_state as long as not during connection/re-connection,
# as last state will be used to restore the previous state
if (state is not None) and (not self._device.is_connecting):
self._last_state = state
def status_restored(self, stored_state): def status_restored(self, stored_state):
"""Device status was restored. """Device status was restored.
Override in subclasses and update entity specific state. Override in subclasses and update entity specific state.
""" """
raw_state = stored_state.attributes.get(ATTR_STATE)
if raw_state is not None:
self._last_state = raw_state
self.debug(
"Restoring state for entity: %s - state: %s",
self.name,
str(self._last_state),
)
def default_value(self):
"""Return default value of this entity.
Override in subclasses to specify the default value for the entity.
"""
# Check if default value has been set - if not, default to the entity defaults.
if self._default_value is None:
self._default_value = self.entity_default_value()
return self._default_value
def entity_default_value(self): # pylint: disable=no-self-use
"""Return default value of the entity type.
Override in subclasses to specify the default value for the entity.
"""
return 0
@property
def restore_on_reconnect(self):
"""Return whether the last state should be restored on a reconnect.
Useful where the device loses settings if powered off
"""
return self._restore_on_reconnect
async def restore_state_when_connected(self):
"""Restore if restore_on_reconnect is set, or if no status has been yet found.
Which indicates a DPS that needs to be set before it starts returning
status.
"""
if not self.restore_on_reconnect and (str(self._dp_id) in self._status):
self.debug(
"Entity %s (DP %d) - Not restoring as restore on reconnect is \
disabled for this entity and the entity has an initial status",
self.name,
self._dp_id,
)
return
self.debug("Attempting to restore state for entity: %s", self.name)
# Attempt to restore the current state - in case reset.
restore_state = self._state
# If no state stored in the entity currently, go from last saved state
if (restore_state == STATE_UNKNOWN) | (restore_state is None):
self.debug("No current state for entity")
restore_state = self._last_state
# If no current or saved state, then use the default value
if restore_state is None:
self.debug("No last restored state - using default")
restore_state = self.default_value()
self.debug(
"Entity %s (DP %d) - Restoring state: %s",
self.name,
self._dp_id,
str(restore_state),
)
# Manually initialise
await self._device.set_dp(restore_state, self._dp_id)

View File

@@ -38,12 +38,14 @@ from .const import (
CONF_NO_CLOUD, CONF_NO_CLOUD,
CONF_PRODUCT_NAME, CONF_PRODUCT_NAME,
CONF_PROTOCOL_VERSION, CONF_PROTOCOL_VERSION,
CONF_RESET_DPIDS,
CONF_SETUP_CLOUD, CONF_SETUP_CLOUD,
CONF_USER_ID, CONF_USER_ID,
DATA_CLOUD, DATA_CLOUD,
DATA_DISCOVERY, DATA_DISCOVERY,
DOMAIN, DOMAIN,
PLATFORMS, PLATFORMS,
CONF_MANUAL_DPS,
) )
from .discovery import discover from .discovery import discover
@@ -88,6 +90,8 @@ CONFIGURE_DEVICE_SCHEMA = vol.Schema(
vol.Required(CONF_DEVICE_ID): str, vol.Required(CONF_DEVICE_ID): str,
vol.Required(CONF_PROTOCOL_VERSION, default="3.3"): vol.In(["3.1", "3.3"]), vol.Required(CONF_PROTOCOL_VERSION, default="3.3"): vol.In(["3.1", "3.3"]),
vol.Optional(CONF_SCAN_INTERVAL): int, vol.Optional(CONF_SCAN_INTERVAL): int,
vol.Optional(CONF_MANUAL_DPS): str,
vol.Optional(CONF_RESET_DPIDS): str,
} }
) )
@@ -99,6 +103,8 @@ DEVICE_SCHEMA = vol.Schema(
vol.Required(CONF_FRIENDLY_NAME): cv.string, vol.Required(CONF_FRIENDLY_NAME): cv.string,
vol.Required(CONF_PROTOCOL_VERSION, default="3.3"): vol.In(["3.1", "3.3"]), vol.Required(CONF_PROTOCOL_VERSION, default="3.3"): vol.In(["3.1", "3.3"]),
vol.Optional(CONF_SCAN_INTERVAL): int, vol.Optional(CONF_SCAN_INTERVAL): int,
vol.Optional(CONF_MANUAL_DPS): cv.string,
vol.Optional(CONF_RESET_DPIDS): str,
} }
) )
@@ -140,6 +146,8 @@ def options_schema(entities):
vol.Required(CONF_LOCAL_KEY): str, vol.Required(CONF_LOCAL_KEY): str,
vol.Required(CONF_PROTOCOL_VERSION, default="3.3"): vol.In(["3.1", "3.3"]), vol.Required(CONF_PROTOCOL_VERSION, default="3.3"): vol.In(["3.1", "3.3"]),
vol.Optional(CONF_SCAN_INTERVAL): int, vol.Optional(CONF_SCAN_INTERVAL): int,
vol.Optional(CONF_MANUAL_DPS): str,
vol.Optional(CONF_RESET_DPIDS): str,
vol.Required( vol.Required(
CONF_ENTITIES, description={"suggested_value": entity_names} CONF_ENTITIES, description={"suggested_value": entity_names}
): cv.multi_select(entity_names), ): cv.multi_select(entity_names),
@@ -231,6 +239,8 @@ async def validate_input(hass: core.HomeAssistant, data):
detected_dps = {} detected_dps = {}
interface = None interface = None
reset_ids = None
try: try:
interface = await pytuya.connect( interface = await pytuya.connect(
data[CONF_HOST], data[CONF_HOST],
@@ -239,7 +249,42 @@ async def validate_input(hass: core.HomeAssistant, data):
float(data[CONF_PROTOCOL_VERSION]), float(data[CONF_PROTOCOL_VERSION]),
) )
detected_dps = await interface.detect_available_dps() try:
detected_dps = await interface.detect_available_dps()
except Exception: # pylint: disable=broad-except
try:
_LOGGER.debug("Initial state update failed, trying reset command")
if CONF_RESET_DPIDS in data:
reset_ids_str = data[CONF_RESET_DPIDS].split(",")
reset_ids = []
for reset_id in reset_ids_str:
reset_ids.append(int(reset_id.strip()))
_LOGGER.debug(
"Reset DPIDs configured: %s (%s)",
data[CONF_RESET_DPIDS],
reset_ids,
)
await interface.reset(reset_ids)
detected_dps = await interface.detect_available_dps()
except Exception: # pylint: disable=broad-except
_LOGGER.debug("No DPS able to be detected")
detected_dps = {}
# if manual DPs are set, merge these.
_LOGGER.debug("Detected DPS: %s", detected_dps)
if CONF_MANUAL_DPS in data:
manual_dps_list = [dps.strip() for dps in data[CONF_MANUAL_DPS].split(",")]
_LOGGER.debug(
"Manual DPS Setting: %s (%s)", data[CONF_MANUAL_DPS], manual_dps_list
)
# merge the lists
for new_dps in manual_dps_list + (reset_ids or []):
# If the DPS not in the detected dps list, then add with a
# default value indicating that it has been manually added
if new_dps not in detected_dps:
detected_dps[new_dps] = -1
except (ConnectionRefusedError, ConnectionResetError) as ex: except (ConnectionRefusedError, ConnectionResetError) as ex:
raise CannotConnect from ex raise CannotConnect from ex
except ValueError as ex: except ValueError as ex:
@@ -253,6 +298,8 @@ async def validate_input(hass: core.HomeAssistant, data):
if not detected_dps: if not detected_dps:
raise EmptyDpsList raise EmptyDpsList
_LOGGER.debug("Total DPS: %s", detected_dps)
return dps_string_list(detected_dps) return dps_string_list(detected_dps)

View File

@@ -35,11 +35,15 @@ CONF_PRODUCT_KEY = "product_key"
CONF_PRODUCT_NAME = "product_name" CONF_PRODUCT_NAME = "product_name"
CONF_USER_ID = "user_id" CONF_USER_ID = "user_id"
CONF_ACTION = "action" CONF_ACTION = "action"
CONF_ADD_DEVICE = "add_device" CONF_ADD_DEVICE = "add_device"
CONF_EDIT_DEVICE = "edit_device" CONF_EDIT_DEVICE = "edit_device"
CONF_SETUP_CLOUD = "setup_cloud" CONF_SETUP_CLOUD = "setup_cloud"
CONF_NO_CLOUD = "no_cloud" CONF_NO_CLOUD = "no_cloud"
CONF_MANUAL_DPS = "manual_dps_strings"
CONF_DEFAULT_VALUE = "dps_default_value"
CONF_RESET_DPIDS = "reset_dpids"
# light # light
CONF_BRIGHTNESS_LOWER = "brightness_lower" CONF_BRIGHTNESS_LOWER = "brightness_lower"
@@ -114,3 +118,16 @@ CONF_FAULT_DP = "fault_dp"
CONF_PAUSED_STATE = "paused_state" CONF_PAUSED_STATE = "paused_state"
CONF_RETURN_MODE = "return_mode" CONF_RETURN_MODE = "return_mode"
CONF_STOP_STATUS = "stop_status" CONF_STOP_STATUS = "stop_status"
# number
CONF_MIN_VALUE = "min_value"
CONF_MAX_VALUE = "max_value"
CONF_STEPSIZE_VALUE = "step_size"
# select
CONF_OPTIONS = "select_options"
CONF_OPTIONS_FRIENDLY = "select_options_friendly"
# States
ATTR_STATE = "raw_state"
CONF_RESTORE_ON_RECONNECT = "restore_on_reconnect"

View File

@@ -228,5 +228,10 @@ class LocaltuyaCover(LocalTuyaEntity, CoverEntity):
# store the time of the last movement change # store the time of the last movement change
self._timer_start = time.time() self._timer_start = time.time()
# Keep record in last_state as long as not during connection/re-connection,
# as last state will be used to restore the previous state
if (self._state is not None) and (not self._device.is_connecting):
self._last_state = self._state
async_setup_entry = partial(async_setup_entry, DOMAIN, LocaltuyaCover, flow_schema) async_setup_entry = partial(async_setup_entry, DOMAIN, LocaltuyaCover, flow_schema)

View File

@@ -1,7 +1,7 @@
{ {
"domain": "localtuya", "domain": "localtuya",
"name": "LocalTuya integration", "name": "LocalTuya integration",
"version": "4.0.2", "version": "4.1.0",
"documentation": "https://github.com/rospogrigio/localtuya/", "documentation": "https://github.com/rospogrigio/localtuya/",
"dependencies": [], "dependencies": [],
"codeowners": [ "codeowners": [

View File

@@ -8,13 +8,19 @@ from homeassistant.const import CONF_DEVICE_CLASS, STATE_UNKNOWN
from .common import LocalTuyaEntity, async_setup_entry from .common import LocalTuyaEntity, async_setup_entry
_LOGGER = logging.getLogger(__name__) from .const import (
CONF_MIN_VALUE,
CONF_MAX_VALUE,
CONF_DEFAULT_VALUE,
CONF_RESTORE_ON_RECONNECT,
CONF_STEPSIZE_VALUE,
)
CONF_MIN_VALUE = "native_min_value" _LOGGER = logging.getLogger(__name__)
CONF_MAX_VALUE = "native_max_value"
DEFAULT_MIN = 0 DEFAULT_MIN = 0
DEFAULT_MAX = 100000 DEFAULT_MAX = 100000
DEFAULT_STEP = 1.0
def flow_schema(dps): def flow_schema(dps):
@@ -28,6 +34,12 @@ def flow_schema(dps):
vol.Coerce(float), vol.Coerce(float),
vol.Range(min=-1000000.0, max=1000000.0), vol.Range(min=-1000000.0, max=1000000.0),
), ),
vol.Required(CONF_STEPSIZE_VALUE, default=DEFAULT_STEP): vol.All(
vol.Coerce(float),
vol.Range(min=0.0, max=1000000.0),
),
vol.Optional(CONF_DEFAULT_VALUE): str,
vol.Required(CONF_RESTORE_ON_RECONNECT): bool,
} }
@@ -49,7 +61,18 @@ class LocaltuyaNumber(LocalTuyaEntity, NumberEntity):
if CONF_MIN_VALUE in self._config: if CONF_MIN_VALUE in self._config:
self._min_value = self._config.get(CONF_MIN_VALUE) self._min_value = self._config.get(CONF_MIN_VALUE)
self._max_value = self._config.get(CONF_MAX_VALUE) self._max_value = DEFAULT_MAX
if CONF_MAX_VALUE in self._config:
self._max_value = self._config.get(CONF_MAX_VALUE)
self._step_size = DEFAULT_STEP
if CONF_STEPSIZE_VALUE in self._config:
self._step_size = self._config.get(CONF_STEPSIZE_VALUE)
# Override standard default value handling to cast to a float
default_value = self._config.get(CONF_DEFAULT_VALUE)
if default_value is not None:
self._default_value = float(default_value)
@property @property
def native_value(self) -> float: def native_value(self) -> float:
@@ -66,6 +89,11 @@ class LocaltuyaNumber(LocalTuyaEntity, NumberEntity):
"""Return the maximum value.""" """Return the maximum value."""
return self._max_value return self._max_value
@property
def native_step(self) -> float:
"""Return the maximum value."""
return self._step_size
@property @property
def device_class(self): def device_class(self):
"""Return the class of this device.""" """Return the class of this device."""
@@ -75,10 +103,10 @@ class LocaltuyaNumber(LocalTuyaEntity, NumberEntity):
"""Update the current value.""" """Update the current value."""
await self._device.set_dp(value, self._dp_id) await self._device.set_dp(value, self._dp_id)
def status_updated(self): # Default value is the minimum value
"""Device status was updated.""" def entity_default_value(self):
state = self.dps(self._dp_id) """Return the minimum value as the default for this entity type."""
self._state = state return self._min_value
async_setup_entry = partial(async_setup_entry, DOMAIN, LocaltuyaNumber, flow_schema) async_setup_entry = partial(async_setup_entry, DOMAIN, LocaltuyaNumber, flow_schema)

View File

@@ -62,6 +62,7 @@ TuyaMessage = namedtuple("TuyaMessage", "seqno cmd retcode payload crc")
SET = "set" SET = "set"
STATUS = "status" STATUS = "status"
HEARTBEAT = "heartbeat" HEARTBEAT = "heartbeat"
RESET = "reset"
UPDATEDPS = "updatedps" # Request refresh of DPS UPDATEDPS = "updatedps" # Request refresh of DPS
PROTOCOL_VERSION_BYTES_31 = b"3.1" PROTOCOL_VERSION_BYTES_31 = b"3.1"
@@ -96,6 +97,16 @@ PAYLOAD_DICT = {
SET: {"hexByte": 0x07, "command": {"devId": "", "uid": "", "t": ""}}, SET: {"hexByte": 0x07, "command": {"devId": "", "uid": "", "t": ""}},
HEARTBEAT: {"hexByte": 0x09, "command": {}}, HEARTBEAT: {"hexByte": 0x09, "command": {}},
UPDATEDPS: {"hexByte": 0x12, "command": {"dpId": [18, 19, 20]}}, UPDATEDPS: {"hexByte": 0x12, "command": {"dpId": [18, 19, 20]}},
RESET: {
"hexByte": 0x12,
"command": {
"gwId": "",
"devId": "",
"uid": "",
"t": "",
"dpId": [18, 19, 20],
},
},
}, },
"type_0d": { "type_0d": {
STATUS: {"hexByte": 0x0D, "command": {"devId": "", "uid": "", "t": ""}}, STATUS: {"hexByte": 0x0D, "command": {"devId": "", "uid": "", "t": ""}},
@@ -217,6 +228,7 @@ class MessageDispatcher(ContextualLogger):
# Heartbeats always respond with sequence number 0, so they can't be waited for like # Heartbeats always respond with sequence number 0, so they can't be waited for like
# other messages. This is a hack to allow waiting for heartbeats. # other messages. This is a hack to allow waiting for heartbeats.
HEARTBEAT_SEQNO = -100 HEARTBEAT_SEQNO = -100
RESET_SEQNO = -101
def __init__(self, dev_id, listener): def __init__(self, dev_id, listener):
"""Initialize a new MessageBuffer.""" """Initialize a new MessageBuffer."""
@@ -301,9 +313,19 @@ class MessageDispatcher(ContextualLogger):
sem.release() sem.release()
elif msg.cmd == 0x12: elif msg.cmd == 0x12:
self.debug("Got normal updatedps response") self.debug("Got normal updatedps response")
if self.RESET_SEQNO in self.listeners:
sem = self.listeners[self.RESET_SEQNO]
self.listeners[self.RESET_SEQNO] = msg
sem.release()
elif msg.cmd == 0x08: elif msg.cmd == 0x08:
self.debug("Got status update") if self.RESET_SEQNO in self.listeners:
self.listener(msg) self.debug("Got reset status update")
sem = self.listeners[self.RESET_SEQNO]
self.listeners[self.RESET_SEQNO] = msg
sem.release()
else:
self.debug("Got status update")
self.listener(msg)
else: else:
self.debug( self.debug(
"Got message type %d for unknown listener %d: %s", "Got message type %d for unknown listener %d: %s",
@@ -381,6 +403,11 @@ class TuyaProtocol(asyncio.Protocol, ContextualLogger):
def connection_made(self, transport): def connection_made(self, transport):
"""Did connect to the device.""" """Did connect to the device."""
self.transport = transport
self.on_connected.set_result(True)
def start_heartbeat(self):
"""Start the heartbeat transmissions with the device."""
async def heartbeat_loop(): async def heartbeat_loop():
"""Continuously send heart beat updates.""" """Continuously send heart beat updates."""
@@ -403,8 +430,6 @@ class TuyaProtocol(asyncio.Protocol, ContextualLogger):
self.transport = None self.transport = None
transport.close() transport.close()
self.transport = transport
self.on_connected.set_result(True)
self.heartbeater = self.loop.create_task(heartbeat_loop()) self.heartbeater = self.loop.create_task(heartbeat_loop())
def data_received(self, data): def data_received(self, data):
@@ -449,12 +474,13 @@ class TuyaProtocol(asyncio.Protocol, ContextualLogger):
payload = self._generate_payload(command, dps) payload = self._generate_payload(command, dps)
dev_type = self.dev_type dev_type = self.dev_type
# Wait for special sequence number if heartbeat # Wait for special sequence number if heartbeat or reset
seqno = ( seqno = self.seqno - 1
MessageDispatcher.HEARTBEAT_SEQNO
if command == HEARTBEAT if command == HEARTBEAT:
else (self.seqno - 1) seqno = MessageDispatcher.HEARTBEAT_SEQNO
) elif command == RESET:
seqno = MessageDispatcher.RESET_SEQNO
self.transport.write(payload) self.transport.write(payload)
msg = await self.dispatcher.wait_for(seqno) msg = await self.dispatcher.wait_for(seqno)
@@ -487,6 +513,15 @@ class TuyaProtocol(asyncio.Protocol, ContextualLogger):
"""Send a heartbeat message.""" """Send a heartbeat message."""
return await self.exchange(HEARTBEAT) return await self.exchange(HEARTBEAT)
async def reset(self, dpIds=None):
"""Send a reset message (3.3 only)."""
if self.version == 3.3:
self.dev_type = "type_0a"
self.debug("reset switching to dev_type %s", self.dev_type)
return await self.exchange(RESET, dpIds)
return True
async def update_dps(self, dps=None): async def update_dps(self, dps=None):
""" """
Request device to update index. Request device to update index.

View File

@@ -4,14 +4,19 @@ from functools import partial
import voluptuous as vol import voluptuous as vol
from homeassistant.components.select import DOMAIN, SelectEntity from homeassistant.components.select import DOMAIN, SelectEntity
from homeassistant.const import CONF_DEVICE_CLASS, STATE_UNKNOWN from homeassistant.const import (
CONF_DEVICE_CLASS,
STATE_UNKNOWN,
)
from .common import LocalTuyaEntity, async_setup_entry from .common import LocalTuyaEntity, async_setup_entry
_LOGGER = logging.getLogger(__name__) from .const import (
CONF_OPTIONS,
CONF_OPTIONS = "select_options" CONF_OPTIONS_FRIENDLY,
CONF_OPTIONS_FRIENDLY = "select_options_friendly" CONF_DEFAULT_VALUE,
CONF_RESTORE_ON_RECONNECT,
)
def flow_schema(dps): def flow_schema(dps):
@@ -19,9 +24,14 @@ def flow_schema(dps):
return { return {
vol.Required(CONF_OPTIONS): str, vol.Required(CONF_OPTIONS): str,
vol.Optional(CONF_OPTIONS_FRIENDLY): str, vol.Optional(CONF_OPTIONS_FRIENDLY): str,
vol.Optional(CONF_DEFAULT_VALUE): str,
vol.Required(CONF_RESTORE_ON_RECONNECT): bool,
} }
_LOGGER = logging.getLogger(__name__)
class LocaltuyaSelect(LocalTuyaEntity, SelectEntity): class LocaltuyaSelect(LocalTuyaEntity, SelectEntity):
"""Representation of a Tuya Enumeration.""" """Representation of a Tuya Enumeration."""
@@ -92,9 +102,24 @@ class LocaltuyaSelect(LocalTuyaEntity, SelectEntity):
def status_updated(self): def status_updated(self):
"""Device status was updated.""" """Device status was updated."""
super().status_updated()
state = self.dps(self._dp_id) state = self.dps(self._dp_id)
self._state_friendly = self._display_options[self._valid_options.index(state)]
self._state = state # Check that received status update for this entity.
if state is not None:
try:
self._state_friendly = self._display_options[
self._valid_options.index(state)
]
except Exception: # pylint: disable=broad-except
# Friendly value couldn't be mapped
self._state_friendly = state
# Default value is the first option
def entity_default_value(self):
"""Return the first option as the default value for this entity type."""
return self._valid_options[0]
async_setup_entry = partial(async_setup_entry, DOMAIN, LocaltuyaSelect, flow_schema) async_setup_entry = partial(async_setup_entry, DOMAIN, LocaltuyaSelect, flow_schema)

View File

@@ -66,5 +66,10 @@ class LocaltuyaSensor(LocalTuyaEntity):
state = round(state * scale_factor, DEFAULT_PRECISION) state = round(state * scale_factor, DEFAULT_PRECISION)
self._state = state self._state = state
# No need to restore state for a sensor
async def restore_state_when_connected(self):
"""Do nothing for a sensor."""
return
async_setup_entry = partial(async_setup_entry, DOMAIN, LocaltuyaSensor, flow_schema) async_setup_entry = partial(async_setup_entry, DOMAIN, LocaltuyaSensor, flow_schema)

View File

@@ -121,7 +121,12 @@
"preset_set": "Presets Set (optional)", "preset_set": "Presets Set (optional)",
"eco_dp": "Eco DP (optional)", "eco_dp": "Eco DP (optional)",
"eco_value": "Eco value (optional)", "eco_value": "Eco value (optional)",
"heuristic_action": "Enable heuristic action (optional)" "heuristic_action": "Enable heuristic action (optional)",
"dps_default_value": "Default value when un-initialised (optional)",
"restore_on_reconnect": "Restore the last set value in HomeAssistant after a lost connection",
"min_value": "Minimum Value",
"max_value": "Maximum Value",
"step_size": "Minimum increment between numbers"
} }
}, },
"yaml_import": { "yaml_import": {

View File

@@ -10,9 +10,12 @@ from .const import (
ATTR_CURRENT, ATTR_CURRENT,
ATTR_CURRENT_CONSUMPTION, ATTR_CURRENT_CONSUMPTION,
ATTR_VOLTAGE, ATTR_VOLTAGE,
ATTR_STATE,
CONF_CURRENT, CONF_CURRENT,
CONF_CURRENT_CONSUMPTION, CONF_CURRENT_CONSUMPTION,
CONF_VOLTAGE, CONF_VOLTAGE,
CONF_DEFAULT_VALUE,
CONF_RESTORE_ON_RECONNECT,
) )
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -24,6 +27,8 @@ def flow_schema(dps):
vol.Optional(CONF_CURRENT): vol.In(dps), vol.Optional(CONF_CURRENT): vol.In(dps),
vol.Optional(CONF_CURRENT_CONSUMPTION): vol.In(dps), vol.Optional(CONF_CURRENT_CONSUMPTION): vol.In(dps),
vol.Optional(CONF_VOLTAGE): vol.In(dps), vol.Optional(CONF_VOLTAGE): vol.In(dps),
vol.Optional(CONF_DEFAULT_VALUE): str,
vol.Required(CONF_RESTORE_ON_RECONNECT): bool,
} }
@@ -59,6 +64,12 @@ class LocaltuyaSwitch(LocalTuyaEntity, SwitchEntity):
) )
if self.has_config(CONF_VOLTAGE): if self.has_config(CONF_VOLTAGE):
attrs[ATTR_VOLTAGE] = self.dps(self._config[CONF_VOLTAGE]) / 10 attrs[ATTR_VOLTAGE] = self.dps(self._config[CONF_VOLTAGE]) / 10
# Store the state
if self._state is not None:
attrs[ATTR_STATE] = self._state
elif self._last_state is not None:
attrs[ATTR_STATE] = self._last_state
return attrs return attrs
async def async_turn_on(self, **kwargs): async def async_turn_on(self, **kwargs):
@@ -69,9 +80,10 @@ class LocaltuyaSwitch(LocalTuyaEntity, SwitchEntity):
"""Turn Tuya switch off.""" """Turn Tuya switch off."""
await self._device.set_dp(False, self._dp_id) await self._device.set_dp(False, self._dp_id)
def status_updated(self): # Default value is the "OFF" state
"""Device status was updated.""" def entity_default_value(self):
self._state = self.dps(self._dp_id) """Return False as the defaualt value for this entity type."""
return False
async_setup_entry = partial(async_setup_entry, DOMAIN, LocaltuyaSwitch, flow_schema) async_setup_entry = partial(async_setup_entry, DOMAIN, LocaltuyaSwitch, flow_schema)

View File

@@ -96,7 +96,9 @@
"local_key": "Local key", "local_key": "Local key",
"protocol_version": "Protocol Version", "protocol_version": "Protocol Version",
"scan_interval": "Scan interval (seconds, only when not updating automatically)", "scan_interval": "Scan interval (seconds, only when not updating automatically)",
"entities": "Entities (uncheck an entity to remove it)" "entities": "Entities (uncheck an entity to remove it)",
"manual_dps_strings": "Manual DPS to add (separated by commas ',') - used when detection is not working (optional)",
"reset_dpids": "DPIDs to send in RESET command (separated by commas ',')- Used when device does not respond to status requests after turning on (optional)"
} }
}, },
"pick_entity_type": { "pick_entity_type": {
@@ -182,7 +184,12 @@
"preset_set": "Presets Set (optional)", "preset_set": "Presets Set (optional)",
"eco_dp": "Eco DP (optional)", "eco_dp": "Eco DP (optional)",
"eco_value": "Eco value (optional)", "eco_value": "Eco value (optional)",
"heuristic_action": "Enable heuristic action (optional)" "heuristic_action": "Enable heuristic action (optional)",
"dps_default_value": "Default value when un-initialised (optional)",
"restore_on_reconnect": "Restore the last set value in HomeAssistant after a lost connection",
"min_value": "Minimum Value",
"max_value": "Maximum Value",
"step_size": "Minimum increment between numbers"
} }
} }
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 29 KiB

View File

@@ -96,10 +96,13 @@ If you have selected one entry, you only need to input the device's Friendly Nam
Setting the scan interval is optional, it is only needed if energy/power values are not updating frequently enough by default. Values less than 10 seconds may cause stability issues. Setting the scan interval is optional, it is only needed if energy/power values are not updating frequently enough by default. Values less than 10 seconds may cause stability issues.
Setting the 'Manual DPS To Add' is optional, it is only needed if the device doesn't advertise the DPS correctly until the entity has been properly initiailised. This setting can often be avoided by first connecting/initialising the device with the Tuya App, then closing the app and then adding the device in the integration.
Setting the 'DPIDs to send in RESET command' is optional. It is used when a device doesn't respond to any Tuya commands after a power cycle, but can be connected to (zombie state). The DPids will vary between devices, but typically "18,19,20" is used (and will be the default if none specified). If the wrong entries are added here, then the device may not come out of the zombie state. Typically only sensor DPIDs entered here.
Once you press "Submit", the connection is tested to check that everything works. 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) ![image](https://github.com/rospogrigio/localtuya-homeassistant/blob/master/img/2-device.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. 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. After you have defined all the needed entities, leave the "Do not add more entities" checkbox checked: this will complete the procedure.