diff --git a/custom_components/localtuya/common.py b/custom_components/localtuya/common.py index 01bba0d64..6bd46d94c 100644 --- a/custom_components/localtuya/common.py +++ b/custom_components/localtuya/common.py @@ -25,6 +25,7 @@ EntityCategory, CONF_ICON, STATE_UNAVAILABLE, + Platform, ) from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import ( @@ -56,10 +57,12 @@ CONF_SCALING, CONF_DEVICE_SLEEP_TIME, CONF_DPS_STRINGS, + CONF_MANUAL_DPS, ) _LOGGER = logging.getLogger(__name__) RESTORE_STATES = {"0": "restore"} +BYPASS_STATUS = {"0": "bypass"} async def async_setup_entry( @@ -359,6 +362,9 @@ def _new_entity_handler(entity_id): ] await asyncio.gather(*connect_sub_devices) + if "0" in self._device_config.get(CONF_MANUAL_DPS, "").split(","): + self.status_updated(BYPASS_STATUS) + if self._pending_status: await self.set_dps(self._pending_status) self._pending_status = {} @@ -566,6 +572,7 @@ def __init__( self._status = {} self._state = None self._last_state = None + self._hass = device._hass # Default value is available to be provided by Platform entities if required self._default_value = self._config.get(CONF_DEFAULT_VALUE) @@ -581,18 +588,17 @@ async def async_added_to_hass(self): self.debug(f"Adding {self.entity_id} with configuration: {self._config}") - state = await self.async_get_last_state() - if state: - self.status_restored(state) + stored_data = await self.async_get_last_state() + if stored_data: + self.status_restored(stored_data) - async def _update_handler(status): + def _update_handler(status): """Update entity state when status was updated.""" if status is None: status = {} if self._status != status: if status == RESTORE_STATES: status = {} - stored_data: State = await self.async_get_last_state() self.debug(f"Device is sleep restored state: {stored_data.state}") if stored_data and stored_data.state != STATE_UNAVAILABLE: status = {self._dp_id: stored_data.state} @@ -660,6 +666,9 @@ def unique_id(self) -> str: @property def available(self) -> bool: """Return if device is available or not.""" + if self._status == BYPASS_STATUS: + return True + return len(self._status) > 0 @property diff --git a/custom_components/localtuya/const.py b/custom_components/localtuya/const.py index dad14aba4..a6b906474 100644 --- a/custom_components/localtuya/const.py +++ b/custom_components/localtuya/const.py @@ -21,6 +21,7 @@ "Humidifier": Platform.HUMIDIFIER, "Light": Platform.LIGHT, "Number": Platform.NUMBER, + "Remote": Platform.REMOTE, "Select": Platform.SELECT, "Sensor": Platform.SENSOR, "Siren": Platform.SIREN, @@ -157,6 +158,10 @@ CONF_OPTIONS = "select_options" CONF_OPTIONS_FRIENDLY = "select_options_friendly" +# Remote +CONF_SEND_DP = "send_dp" +CONF_RECEIVE_DP = "receive_dp" + # States ATTR_STATE = "raw_state" CONF_RESTORE_ON_RECONNECT = "restore_on_reconnect" diff --git a/custom_components/localtuya/core/pytuya/__init__.py b/custom_components/localtuya/core/pytuya/__init__.py index de3e37e62..285971ee3 100644 --- a/custom_components/localtuya/core/pytuya/__init__.py +++ b/custom_components/localtuya/core/pytuya/__init__.py @@ -1479,6 +1479,73 @@ def __repr__(self): return self.id +class api: + def __init__( + self, + host: str, + device_id: str, + local_key: str, + enable_debug: bool, + protocol_version: float, + listener: TuyaListener = None, + timeout: int = 5, + port: int = 6668, + ) -> None: + """ + attributes: + host: The local IP Address of the device. + device_id: the ID of the device. + local_key: device payload, encryption key. + enable_debug: Enable the debug logs for the device. + protocol_version: The protocol version of the device # 3.1, 3.2, 3.3, 3.4 or 3.5 + listener: class listener. + """ + self._device_id = device_id + self._host = host + self._local_key = local_key + self._enable_debug = enable_debug + self._protocol_version = protocol_version + self._listener = listener + self._timeout = timeout + self._port = port + + # + self.connected: bool + self.is_connecting: bool + self.enable_reconnect: bool = True + + self.manager: TuyaProtocol + + async def connect(self): + if not self.enable_reconnect: + return + loop = asyncio.get_running_loop() + on_connected = loop.create_future() + try: + _, protocol = await loop.create_connection( + lambda: TuyaProtocol( + self._device_id, + self._local_key, + self._protocol_version, + self._enable_debug, + on_connected, + self._listener or EmptyListener(), + ), + self._host, + self._port, + ) + except OSError as ex: + raise ValueError(str(ex)) + except Exception as ex: + raise ex + except: + raise ValueError(f"Unable to connect to the device. try again.") + + await asyncio.wait_for(on_connected, timeout=self._timeout) + self.manager = protocol + return protocol + + async def connect( address, device_id, diff --git a/custom_components/localtuya/remote.py b/custom_components/localtuya/remote.py new file mode 100644 index 000000000..1830b5856 --- /dev/null +++ b/custom_components/localtuya/remote.py @@ -0,0 +1,336 @@ +"""Platform to present any Tuya DP as a remote.""" + +import asyncio +import json +import base64 +import logging +from functools import partial +import struct +from enum import StrEnum +from typing import Any, Iterable + +import voluptuous as vol +from homeassistant.components.remote import ( + ATTR_ACTIVITY, + ATTR_COMMAND, + ATTR_COMMAND_TYPE, + ATTR_NUM_REPEATS, + ATTR_DELAY_SECS, + ATTR_DEVICE, + ATTR_TIMEOUT, + DOMAIN, + RemoteEntity, + RemoteEntityFeature, +) +from homeassistant.components import persistent_notification +from homeassistant.const import CONF_DEVICE_ID, STATE_OFF +from homeassistant.core import HomeAssistant, State +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers.storage import Store + +from .common import LocalTuyaEntity, async_setup_entry +from .const import CONF_SEND_DP, CONF_RECEIVE_DP + +NSDP_CONTROL = "control" # The control commands +NSDP_TYPE = "type" # The identifier of an IR library +NSDP_HEAD = "head" # Actually used but not documented +NSDP_KEY1 = "key1" # Actually used but not documented + +_LOGGER = logging.getLogger(__name__) + + +class ControlType(StrEnum): + SEND_IR = "send_ir" + STUDY = "study" + STUDY_EXIT = "study_exit" + + +class RemoteDP(StrEnum): + DP_SEND = "201" + DP_RECIEVE = "202" + + +CODE_STORAGE_VERSION = 1 +SOTRAGE_KEY = "localtuya_remotes_codes" + + +def flow_schema(dps): + """Return schema used in config flow.""" + return { + vol.Optional(CONF_SEND_DP, default=RemoteDP.DP_SEND.value): vol.All( + vol.Coerce(int), vol.Range(min=1, max=300) + ), + vol.Optional(CONF_RECEIVE_DP, default=RemoteDP.DP_RECIEVE.value): vol.All( + vol.Coerce(int), vol.Range(min=1, max=300) + ), + } + + +class LocalTuyaRemote(LocalTuyaEntity, RemoteEntity): + """Representation of a Tuya remote.""" + + def __init__( + self, + device, + config_entry, + remoteid, + **kwargs, + ): + """Initialize the Tuya remote.""" + super().__init__(device, config_entry, remoteid, _LOGGER, **kwargs) + + self._dp_send = str(self._config.get(CONF_SEND_DP, RemoteDP.DP_SEND)) + self._dp_recieve = str(self._config.get(CONF_SEND_DP, RemoteDP.DP_RECIEVE)) + + self._device_id = self._device_config[CONF_DEVICE_ID] + + # self._attr_activity_list: list = [] + # self._attr_current_activity: str | None = None + + self._last_code = None + + self._codes = {} + + self._codes_storage = Store(self._hass, CODE_STORAGE_VERSION, SOTRAGE_KEY) + + self._storage_loaded = False + + self._attr_supported_features = ( + RemoteEntityFeature.LEARN_COMMAND | RemoteEntityFeature.DELETE_COMMAND + ) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on the remote.""" + self._attr_is_on = True + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the remote.""" + self._attr_is_on = False + self.async_write_ha_state() + + async def async_send_command(self, command: Iterable[str], **kwargs: Any) -> None: + """Send commands to a device.""" + if not self._attr_is_on: + raise ServiceValidationError(f"Remote {self.entity_id} is turned off") + + device = kwargs.get(ATTR_DEVICE) + + repeats: int = kwargs.get(ATTR_NUM_REPEATS) + repeats_delay: float = kwargs.get(ATTR_DELAY_SECS) + + for req in [device, command]: + if not req: + raise ServiceValidationError("Missing required fields") + + if not self._storage_loaded: + await self._async_load_storage() + + code = self._get_code(device, command[0]) + + base64_code = "1" + code + + # base64_code = "" + # if base64_code is None: + # option_value = "" + # _LOGGER.debug("Sending Option: -> " + option_value) + + # pulses = self.pronto_to_pulses(option_value) + # base64_code = "1" + self.pulses_to_base64(pulses) + + if repeats: + current_repeat = 0 + while current_repeat < repeats: + await self.send_signal(ControlType.SEND_IR, base64_code) + if repeats_delay: + await asyncio.sleep(repeats_delay) + current_repeat += 1 + return + + await self.send_signal(ControlType.SEND_IR, base64_code) + + async def async_learn_command(self, **kwargs: Any) -> None: + """Learn a command from a device.""" + if not self._attr_is_on: + raise ServiceValidationError(f"Remote {self.entity_id} is turned off") + + now, timeout = 0, kwargs.get(ATTR_TIMEOUT, 15) + sucess = False + last_code = self._last_code + + device = kwargs.get(ATTR_DEVICE) + command = kwargs.get(ATTR_COMMAND) + # command_type = kwargs.get(ATTR_COMMAND_TYPE) + for req in [device, command]: + if not req: + raise ServiceValidationError("Missing required fields") + + if not self._storage_loaded: + await self._async_load_storage() + + await self.send_signal(ControlType.STUDY) + persistent_notification.async_create( + self.hass, + f"Press the '{command[0]}' button.", + title="Learn command", + notification_id="learn_command", + ) + + try: + while now < timeout: + if last_code != (dp_code := self.dp_value(RemoteDP.DP_RECIEVE)): + self._last_code = dp_code + sucess = True + await self.send_signal(ControlType.STUDY_EXIT) + break + + now += 1 + await asyncio.sleep(1) + + if not sucess: + await self.send_signal(ControlType.STUDY_EXIT) + raise ServiceValidationError("Failed to learn the key") + + finally: + persistent_notification.async_dismiss( + self.hass, notification_id="learn_command" + ) + + # code retrive sucess and it's sotred in self._last_code + # we will store the codes. + await self._save_new_command(device, command[0], self._last_code) + + async def async_delete_command(self, **kwargs: Any) -> None: + """Delete commands from the database.""" + device = kwargs.get(ATTR_DEVICE) + command = kwargs.get(ATTR_COMMAND) + + for req in [device, command]: + if not req: + raise ServiceValidationError("Missing required fields") + + if not self._storage_loaded: + await self._async_load_storage() + + await self._delete_command(device, command[0]) + + async def send_signal(self, control, base64_code=None): + command = {NSDP_CONTROL: control} + + if control == ControlType.SEND_IR: + command[NSDP_TYPE] = 0 + command[NSDP_HEAD] = "" + command[NSDP_KEY1] = base64_code + + await self._device.set_dp(json.dumps(command), self._dp_send) + + async def _delete_command(self, device, command) -> None: + """Store new code into stoarge.""" + device_unqiue_id = self._device_id + codes = self._codes + + if device_unqiue_id not in codes: + raise ServiceValidationError(f"IR remote hasn't learned any buttons yet.") + if device not in codes[device_unqiue_id]: + raise ServiceValidationError(f"Couldn't find the device: {device}.") + if command not in codes[device_unqiue_id][device]: + raise ServiceValidationError( + f"Couldn't find the command: {command} for device {device}." + ) + + codes[device_unqiue_id][device].pop(command) + await self._codes_storage.async_save(codes) + + async def _save_new_command(self, device, command, code) -> None: + """Store new code into stoarge.""" + device_unqiue_id = self._device_id + codes = self._codes + + if device_unqiue_id not in codes: + codes[device_unqiue_id] = {} + + # device_data = {command: {ATTR_COMMAND: code, ATTR_COMMAND_TYPE: command_type}} + device_data = {command: code} + + if device in codes[device_unqiue_id]: + codes[device_unqiue_id][device].update(device_data) + else: + codes[device_unqiue_id][device] = device_data + + await self._codes_storage.async_save(codes) + + async def _async_load_storage(self): + """Load code and flag storage from disk.""" + # Exception is intentionally not trapped to + # provide feedback if something fails. + self._codes.update(await self._codes_storage.async_load() or {}) + self._storage_loaded = True + + # No need to restore state for a remote + async def restore_state_when_connected(self): + """Do nothing for a remote.""" + return + + def _get_code(self, device, command): + """Get the code of command from database.""" + + codes_data = self._codes + ir_controller = self._device_id + + if device not in codes_data[ir_controller]: + raise ServiceValidationError(f"Couldn't find the device: {device}.") + if command not in codes_data[ir_controller][device]: + raise ServiceValidationError( + f"Couldn't find the command {command} for device {device}." + ) + + return codes_data[ir_controller][device][command] + + def status_updated(self): + """Device status was updated.""" + state = self.dp_value(self._dp_id) + + def status_restored(self, stored_state: State) -> None: + """Device status was restored..""" + state = stored_state + self._attr_is_on = state is None or state.state != STATE_OFF + + +async_setup_entry = partial(async_setup_entry, DOMAIN, LocalTuyaRemote, flow_schema) + + +def pronto_to_pulses(pronto): + ret = [] + pronto = [int(x, 16) for x in pronto.split(" ")] + ptype = pronto[0] + timebase = pronto[1] + pair1_len = pronto[2] + pair2_len = pronto[3] + if ptype != 0: + # only raw (learned) codes are handled + return ret + if timebase < 90 or timebase > 139: + # only 38 kHz is supported? + return ret + pronto = pronto[4:] + timebase *= 0.241246 + for i in range(0, pair1_len * 2, 2): + ret += [round(pronto[i] * timebase), round(pronto[i + 1] * timebase)] + pronto = pronto[pair1_len * 2 :] + for i in range(0, pair2_len * 2, 2): + ret += [round(pronto[i] * timebase), round(pronto[i + 1] * timebase)] + return ret + + +def pulses_to_base64(pulses): + fmt = "<" + str(len(pulses)) + "H" + return base64.b64encode(struct.pack(fmt, *pulses)).decode("ascii") + + +def base64_to_pulses(code_base_64): + if len(code_base_64) % 4 == 1 and code_base_64.startswith("1"): + # code can be padded with "1" + code_base_64 = code_base_64[1:] + raw_bytes = base64.b64decode(code_base_64) + fmt = "<%dH" % (len(raw_bytes) >> 1) + return list(struct.unpack(fmt, raw_bytes)) diff --git a/custom_components/localtuya/translations/ar.json b/custom_components/localtuya/translations/ar.json index f155fffcd..f6e108a1d 100644 --- a/custom_components/localtuya/translations/ar.json +++ b/custom_components/localtuya/translations/ar.json @@ -233,7 +233,9 @@ "humidifier_set_humidity_dp": "(Optional) Set Humidity DP", "min_humidity": "Set the minimum supported humidity", "max_humidity": "Set the maximum supported humidity", - "alarm_supported_states": "States supported by the device" + "alarm_supported_states": "States supported by the device", + "send_dp":"Sending signals DP. (default is 201)", + "receive_dp":"Receiving signals DP. (default is 202)" }, "data_description": { "hvac_mode_set":"Each line represents [ hvac_mode: device_value ] [Supported HVAC Modes](https://developers.home-assistant.io/docs/core/entity/climate/#hvac-modes)", diff --git a/custom_components/localtuya/translations/en.json b/custom_components/localtuya/translations/en.json index 14452a382..3eb13052c 100644 --- a/custom_components/localtuya/translations/en.json +++ b/custom_components/localtuya/translations/en.json @@ -237,7 +237,9 @@ "humidifier_set_humidity_dp": "(Optional) Set Humidity DP", "min_humidity": "Set the minimum supported humidity", "max_humidity": "Set the maximum supported humidity", - "alarm_supported_states": "States supported by the device" + "alarm_supported_states": "States supported by the device", + "send_dp":"Sending signals DP. (default is 201)", + "receive_dp":"Receiving signals DP. (default is 202)" }, "data_description": { "hvac_mode_set":"Each line represents [ hvac_mode: device_value ] [Supported HVAC Modes](https://developers.home-assistant.io/docs/core/entity/climate/#hvac-modes)", diff --git a/custom_components/localtuya/translations/it.json b/custom_components/localtuya/translations/it.json index 9871ff170..e3ff5e5c5 100644 --- a/custom_components/localtuya/translations/it.json +++ b/custom_components/localtuya/translations/it.json @@ -232,7 +232,9 @@ "humidifier_set_humidity_dp": "(Optional) Set Humidity DP", "min_humidity": "Set the minimum supported humidity", "max_humidity": "Set the maximum supported humidity", - "alarm_supported_states": "States supported by the device" + "alarm_supported_states": "States supported by the device", + "send_dp":"Sending signals DP. (default is 201)", + "receive_dp":"Receiving signals DP. (default is 202)" }, "data_description": { "hvac_mode_set":"Each line represents [ hvac_mode: device_value ] [Supported HVAC Modes](https://developers.home-assistant.io/docs/core/entity/climate/#hvac-modes)", diff --git a/custom_components/localtuya/translations/pl.json b/custom_components/localtuya/translations/pl.json index cca2d8724..00714b9cd 100644 --- a/custom_components/localtuya/translations/pl.json +++ b/custom_components/localtuya/translations/pl.json @@ -237,7 +237,9 @@ "humidifier_set_humidity_dp": "(opcjonalnie) DP ustawienia wilgotności", "min_humidity": "Ustaw minimalną obsługiwaną wilgotność", "max_humidity": "Ustaw maksymalną obsługiwaną wilgotność", - "alarm_supported_states": "States supported by the device" + "alarm_supported_states": "States supported by the device", + "send_dp":"Sending signals DP. (default is 201)", + "receive_dp":"Receiving signals DP. (default is 202)" }, "data_description": { "hvac_mode_set":"Każda linia reprezentuje [ hvac_mode: device_value ] [Obsługiwane tryby HVAC](https://developers.home-assistant.io/docs/core/entity/climate/#hvac-modes)", diff --git a/custom_components/localtuya/translations/pt-BR.json b/custom_components/localtuya/translations/pt-BR.json index 4dffde823..52db3cb35 100644 --- a/custom_components/localtuya/translations/pt-BR.json +++ b/custom_components/localtuya/translations/pt-BR.json @@ -232,7 +232,9 @@ "humidifier_set_humidity_dp": "(Optional) Set Humidity DP", "min_humidity": "Set the minimum supported humidity", "max_humidity": "Set the maximum supported humidity", - "alarm_supported_states": "States supported by the device" + "alarm_supported_states": "States supported by the device", + "send_dp":"Sending signals DP. (default is 201)", + "receive_dp":"Receiving signals DP. (default is 202)" }, "data_description": { "hvac_mode_set":"Each line represents [ hvac_mode: device_value ] [Supported HVAC Modes](https://developers.home-assistant.io/docs/core/entity/climate/#hvac-modes)",