Skip to content

Commit

Permalink
feat(remote): Add sending head/key buttons commands (#550)
Browse files Browse the repository at this point in the history
* add head and key parser and refactor some codes

* add docstrings

* feat: add remote services to handle manual adding of codes

* Remove the extra zero at start of the key
  • Loading branch information
xZetsubou authored Feb 16, 2025
1 parent 6b1aae8 commit a41b700
Show file tree
Hide file tree
Showing 3 changed files with 184 additions and 55 deletions.
12 changes: 8 additions & 4 deletions custom_components/localtuya/entity.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""Code shared between all platforms."""

import logging
from typing import Any
from typing import Any, Coroutine, Callable

from homeassistant.core import HomeAssistant, State
from homeassistant.config_entries import ConfigEntry
Expand Down Expand Up @@ -49,12 +49,13 @@


async def async_setup_entry(
domain,
entity_class,
flow_schema,
domain: str,
entity_class: Any,
flow_schema: Callable,
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
async_setup_services: Coroutine[HomeAssistant, list, None] = None,
):
"""Set up a Tuya platform based on a config entry.
Expand Down Expand Up @@ -104,6 +105,9 @@ async def async_setup_entry(
device.add_entities(entities)
async_add_entities(entities)

if async_setup_services:
await async_setup_services(hass, entities)


def get_dps_for_platform(flow_schema):
"""Return config keys for all platform keys that depends on a datapoint."""
Expand Down
175 changes: 126 additions & 49 deletions custom_components/localtuya/remote.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,9 @@
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.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
Expand Down Expand Up @@ -75,6 +75,20 @@ class RemoteDP(StrEnum):
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"

Expand All @@ -90,14 +104,23 @@ def flow_schema(dps):


def rf_decode_button(base64_code):
"""Decode base64 RF command."""
try:
jstr = base64.b64decode(base64_code)
jdata = json.loads(jstr)
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."""

Expand Down Expand Up @@ -227,7 +250,7 @@ async def async_learn_command(self, **kwargs: Any) -> None:
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)
await self.save_new_command(device, command, self._last_code)
except TimeoutError:
raise ServiceValidationError(f"Timeout: Failed to learn: {command}")
finally:
Expand Down Expand Up @@ -259,57 +282,73 @@ async def async_delete_command(self, **kwargs: Any) -> None:
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

if self._ir_control_type == ControlType.ENUM:
command = {self._dp_id: control}
@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:
command[self._dp_id] = ControlMode.STUDY_KEY.value
command[self._dp_key_study] = base64_code
command["13"] = 0
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:
command = {
NSDP_CONTROL: MODE_IR_TO_RF[control] if (rf_data or rf) else control
}
if rf_data or rf:
if freq := rf_data.get(ATTR_STUDY_FREQ):
command[ATTR_STUDY_FREQ] = freq
if ver := rf_data.get(ATTR_VER):
command[ATTR_VER] = ver

for attr, default_value in (
(ATTR_RF_TYPE, "sub_2g"),
(ATTR_STUDY_FREQ, "433.92"),
(ATTR_VER, "2"),
("feq", "0"),
("rate", "0"),
("mode", "0"),
):
if attr not in command:
command[attr] = default_value

if control == ControlMode.SEND_IR:
command[NSDP_KEY1] = {"code": base64_code}
for attr, default_value in (
(ATTR_TIMES, "6"),
(ATTR_DELAY, "0"),
(ATTR_INTERVALS, "0"),
):
if attr not in command[NSDP_KEY1]:
command[NSDP_KEY1][attr] = default_value
if is_rf:
commands = async_handle_rf_json_type()
else:
if control == ControlMode.SEND_IR:
command[NSDP_TYPE] = 0
command[NSDP_HEAD] = "" # also known as ir_code
command[NSDP_KEY1] = "1" + base64_code # also code: key_code
commands = async_handle_json_type()
commands = {self._dp_id: json.dumps(commands)}

command = {self._dp_id: json.dumps(command)}

self.debug(f"Sending Command: {command}")
self.debug(f"Sending Command: {commands}")
if rf_data:
self.debug(f"Decoded RF Button: {rf_data}")

await self._device.set_dps(command)
await self._device.set_dps(commands)

async def _delete_command(self, device, command) -> None:
"""Store new code into stoarge."""
Expand Down Expand Up @@ -338,8 +377,11 @@ async def _delete_command(self, device, command) -> None:
self._global_codes.pop(device)
await self._codes_storage.async_save(codes_data)

async def _save_new_command(self, device, command, code) -> None:
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

Expand Down Expand Up @@ -417,4 +459,39 @@ def status_restored(self, stored_state: State) -> None:
self._attr_is_on = state is None or state.state != STATE_OFF


async_setup_entry = partial(async_setup_entry, DOMAIN, LocalTuyaRemote, flow_schema)
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,
)
52 changes: 50 additions & 2 deletions custom_components/localtuya/services.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ set_dp:
fields:
device_id:
name: "Device ID"
description: Device ID of device to change datapoint value for
description: The device ID of the device where the datapoint value needs to be changed
required: true
example: 11100118278aab4de001
selector:
Expand All @@ -23,8 +23,56 @@ set_dp:
mode: box
value:
name: "Value"
description: "New value to set or list of dp: value, If value is list target dp will be ignored"
description: "A new value to set or a list of DP-value pairs. If a list is provided, the target DP will be ignored"
required: true
example: '{ "1": True, "2": True }'
selector:
object:

remote_add_code:
name: "Add Remote Code"
description: Add the remote code to the device's remote storage.
fields:
target:
name: "Choose remote device"
description: "Select the remote to store the code on it"
required: true
selector:
device:
multiple: false
entity:
domain: "remote"
filter:
integration: "localtuya"
device_name:
name: "Device Name"
description: The name of the device to store the code in
required: true
example: TV
selector:
text:
command_name:
name: "Command Name"
description: The command name to use when calling it
required: true
example: volume_up
selector:
text:
base64:
name: "Base64 Code"
description: The Base64 code (this will override the head/key values)
required: false
selector:
text:
head:
name: "Head"
description: "The header can be found in the Tuya IoT device debug logs, Key's required"
required: false
selector:
text:
key:
name: "Key"
description: "The key can be found in the Tuya IoT device debug logs, Head's required"
required: false
selector:
text:

0 comments on commit a41b700

Please sign in to comment.