"""Platform to present any Tuya DP as a remote.""" import asyncio import json import base64 import logging from functools import partial from enum import StrEnum from typing import Any, Iterable from .config_flow import col_to_select 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 STATE_OFF from homeassistant.core import ServiceCall, State, callback, HomeAssistant from homeassistant.exceptions import ServiceValidationError, NoEntitySpecifiedError from homeassistant.helpers.storage import Store from .entity import LocalTuyaEntity, async_setup_entry from .const import CONF_RECEIVE_DP, CONF_KEY_STUDY_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): ENUM = "Enum" JSON = "Json" class ControlMode(StrEnum): SEND_IR = "send_ir" STUDY = "study" STUDY_EXIT = "study_exit" STUDY_KEY = "study_key" class RemoteDP(StrEnum): DP_SEND = "201" DP_RECIEVE = "202" MODE_IR_TO_RF = { ControlMode.SEND_IR: "rfstudy_send", ControlMode.STUDY: "rf_study", ControlMode.STUDY_EXIT: "rfstudy_exit", ControlMode.STUDY_KEY: "rf_study", } MODE_RF_TO_SHORT = { MODE_IR_TO_RF[ControlMode.STUDY]: "rf_shortstudy", MODE_IR_TO_RF[ControlMode.STUDY_EXIT]: "rfstudy_exit", } ATTR_FEQ = "feq" ATTR_VER = "ver" ATTR_RF_TYPE = "rf_type" ATTR_TIMES = "times" ATTR_DELAY = "delay" ATTR_INTERVALS = "intervals" ATTR_STUDY_FREQ = "study_feq" RF_DEFAULTS = ( (ATTR_RF_TYPE, "sub_2g"), (ATTR_STUDY_FREQ, "433.92"), (ATTR_VER, "2"), ("feq", "0"), ("rate", "0"), ("mode", "0"), ) SEND_DEFAULTS = ( (ATTR_TIMES, "6"), (ATTR_DELAY, "0"), (ATTR_INTERVALS, "0"), ) CODE_STORAGE_VERSION = 1 SOTRAGE_KEY = "localtuya_remotes_codes" def flow_schema(dps): """Return schema used in config flow.""" return { vol.Optional(CONF_RECEIVE_DP, default=RemoteDP.DP_RECIEVE.value): col_to_select( dps, is_dps=True ), vol.Optional(CONF_KEY_STUDY_DP): col_to_select(dps, is_dps=True), } def rf_decode_button(base64_code): """Decode base64 RF command.""" try: jstr = base64.b64decode(base64_code) jdata: dict = json.loads(jstr) return jdata except: return {} def parse_head_key(head_key: str): """Head and key should looks similar to :HEAD:000:KEY:000. return head, key""" head_key = head_key.split(":HEAD:")[-1] head = head_key.split(":KEY:")[0] key = head_key.split(":KEY:")[1] return head, key 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(self._dp_id, RemoteDP.DP_SEND)) self._dp_recieve = str(self._config.get(CONF_RECEIVE_DP, RemoteDP.DP_RECIEVE)) self._dp_key_study = self._config.get(CONF_KEY_STUDY_DP) self._device_id = self._device_config.id self._lock = asyncio.Lock() self._event = asyncio.Event() # self._attr_activity_list: list = [] # self._attr_current_activity: str | None = None self._last_code = None self._codes = {} # Contains only device commands. self._global_codes = {} # contains all devices commands. 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 ) @property def _ir_control_type(self): if self.has_config(CONF_KEY_STUDY_DP): return ControlType.ENUM else: return ControlType.JSON 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") commands = command device = kwargs.get(ATTR_DEVICE) repeats: int = kwargs.get(ATTR_NUM_REPEATS) repeats_delay: float = kwargs.get(ATTR_DELAY_SECS) for req in [device, commands]: if not req: raise ServiceValidationError("Missing required fields") if not self._storage_loaded: await self._async_load_storage() # 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) for command in commands: code = self._get_code(device, command) base64_code = code if repeats: current_repeat = 0 while current_repeat < repeats: await self.send_signal(ControlMode.SEND_IR, base64_code) if repeats_delay: await asyncio.sleep(repeats_delay) current_repeat += 1 continue await self.send_signal(ControlMode.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, 30) device = kwargs.get(ATTR_DEVICE) commands = kwargs.get(ATTR_COMMAND) is_rf = kwargs.get(ATTR_COMMAND_TYPE) == "rf" # command_type = kwargs.get(ATTR_COMMAND_TYPE) for req in [device, commands]: if not req: raise ServiceValidationError("Missing required fields") if not self._storage_loaded: await self._async_load_storage() if self._lock.locked(): return self.debug("The device is already in learning mode.") async with self._lock: for command in commands: await self.send_signal(ControlMode.STUDY, rf=is_rf) persistent_notification.async_create( self.hass, f"Press the '{command}' button.", title="Learn command", notification_id="learn_command", ) try: self.debug(f"Waiting for code from DP: {self._dp_recieve}") await asyncio.wait_for(self._event.wait(), timeout) await self.save_new_command(device, command, self._last_code) except TimeoutError: raise ServiceValidationError(f"Timeout: Failed to learn: {command}") finally: self._event.clear() await self.send_signal(ControlMode.STUDY_EXIT, rf=is_rf) persistent_notification.async_dismiss( self.hass, notification_id="learn_command" ) # code retrieve success and it's stored in self._last_code # we will store the codes. if command != commands[-1]: await asyncio.sleep(1) async def async_delete_command(self, **kwargs: Any) -> None: """Delete commands from the database.""" device = kwargs.get(ATTR_DEVICE) commands = kwargs.get(ATTR_COMMAND) for req in [device, commands]: if not req: raise ServiceValidationError("Missing required fields") if not self._storage_loaded: await self._async_load_storage() for command in commands: await self._delete_command(device, command) async def send_signal(self, control, base64_code=None, rf=False): """Send command to the remote device.""" rf_data = rf_decode_button(base64_code) is_rf = rf_data or rf @callback def async_handle_enum_type(): """Handle enum type IR.""" commands = {self._dp_id: control, "13": 0} if control == ControlMode.SEND_IR: if all(i in base64_code for i in (":HEAD:", ":KEY:")): head, key = parse_head_key(base64_code) commands["3"] = head commands["4"] = key else: commands[self._dp_id] = ControlMode.STUDY_KEY.value commands[self._dp_key_study] = base64_code return commands @callback def async_handle_json_type(): """Handle json type IR.""" commands = {NSDP_CONTROL: control} if control == ControlMode.SEND_IR: commands[NSDP_TYPE] = 0 if all(i in base64_code for i in (":HEAD:", ":KEY:")): head, key = parse_head_key(base64_code) commands[NSDP_HEAD] = head commands[NSDP_KEY1] = key else: commands[NSDP_HEAD] = "" commands[NSDP_KEY1] = "1" + base64_code return commands @callback def async_handle_rf_json_type(): """Handle json type RF.""" commands = {NSDP_CONTROL: MODE_IR_TO_RF[control]} if freq := rf_data.get(ATTR_STUDY_FREQ): commands[ATTR_STUDY_FREQ] = freq if ver := rf_data.get(ATTR_VER): commands[ATTR_VER] = ver for attr, default_value in RF_DEFAULTS: if attr not in commands: commands[attr] = default_value if control == ControlMode.SEND_IR: commands[NSDP_KEY1] = {"code": base64_code} for attr, default_value in SEND_DEFAULTS: if attr not in commands[NSDP_KEY1]: commands[NSDP_KEY1][attr] = default_value return commands if self._ir_control_type == ControlType.ENUM: commands = async_handle_enum_type() else: if is_rf: commands = async_handle_rf_json_type() else: commands = async_handle_json_type() commands = {self._dp_id: json.dumps(commands)} self.debug(f"Sending Command: {commands}") if rf_data: self.debug(f"Decoded RF Button: {rf_data}") await self._device.set_dps(commands) async def _delete_command(self, device, command) -> None: """Store new code into stoarge.""" codes_data = self._codes ir_controller = self._device_id devices_data = self._global_codes if ir_controller in codes_data and device in codes_data[ir_controller]: devices_data = codes_data[ir_controller] if device not in devices_data: raise ServiceValidationError( f"Couldn't find the device: {device} available devices is on this IR Remote is: {list(devices_data)}." ) commands = devices_data[device] if command not in commands: commands.pop("rf", False) raise ServiceValidationError( f"Couldn't find the command {command} for in {device} device. the available commands for this device is: {list(commands)}" ) # For now this only works if the command is in the list of commands of this device. devices_data[device].pop(command) if device in self._global_codes: self._global_codes.pop(device) await self._codes_storage.async_save(codes_data) async def save_new_command(self, device, command, code) -> None: """Store new code into stoarge.""" if not self._storage_loaded: await self._async_load_storage() 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 self._global_codes[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. # await self._codes_storage._async_migrate_func(1, 1, self._codes) self._codes.update(await self._codes_storage.async_load() or {}) if self._codes: for dev in self._codes.keys(): self._global_codes.update(self._codes[dev]) 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 devices_data = self._global_codes if ir_controller in codes_data and device in codes_data[ir_controller]: devices_data = codes_data[ir_controller] if device not in devices_data: raise ServiceValidationError( f"Couldn't find the device: {device} available devices is on this IR Remote is: {list(devices_data)}." ) commands = devices_data[device] if command not in commands: commands.pop("rf", False) raise ServiceValidationError( f"Couldn't find the command {command} for in {device} device. the available commands for this device is: {list(commands)}" ) command = devices_data[device][command] return command async def _async_migrate_func(self, old_major_version, old_minor_version, old_data): """Migrate to the new version.""" raise NotImplementedError def status_updated(self): """Device status was updated.""" state = self.dp_value(self._dp_id) if (dp_recv := self.dp_value(self._dp_recieve)) != self._last_code: self._last_code = dp_recv self._event.set() 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 def async_setup_services(hass: HomeAssistant, entities: list[LocalTuyaRemote]): """Setup remote services.""" async def _handle_add_key(call: ServiceCall): """Handle add remote key service's action.""" entity = None for ent in entities: if call.data.get("target") == ent.device_entry.id: entity = ent if not entity: raise NoEntitySpecifiedError("The targeted device could not be found") if base65code := call.data.get("base64"): await entity.save_new_command( call.data["device_name"], call.data["command_name"], base65code ) elif (head := call.data.get("head")) and (key := call.data.get("key")): base65code = f":HEAD:{head}:KEY:{key}" await entity.save_new_command( call.data["device_name"], call.data["command_name"], base65code ) else: raise ServiceValidationError( "Ensure that the fields for Raw Base64 code or header/key are valid" ) hass.services.async_register("localtuya", "remote_add_code", _handle_add_key) async_setup_entry = partial( async_setup_entry, DOMAIN, LocalTuyaRemote, flow_schema, async_setup_services=async_setup_services, )