Initial commit.
This commit is contained in:
@@ -0,0 +1,497 @@
|
||||
"""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,
|
||||
)
|
||||
Reference in New Issue
Block a user