Compare commits

...

4 Commits

Author SHA1 Message Date
epenet
f1d088c3d5 Update services 2025-09-16 12:05:18 +00:00
epenet
10c2559e48 Apply suggestions from code review
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-09-16 13:56:04 +02:00
epenet
9ced2677f3 Apply suggestions from code review
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-09-16 13:52:51 +02:00
epenet
cd077b3616 Add send_text_command service to Tuya 2025-09-16 10:36:04 +00:00
7 changed files with 228 additions and 2 deletions

View File

@@ -15,8 +15,9 @@ from tuya_sharing import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers import config_validation as cv, device_registry as dr
from homeassistant.helpers.dispatcher import dispatcher_send
from homeassistant.helpers.typing import ConfigType
from .const import (
CONF_APP_TYPE,
@@ -31,6 +32,9 @@ from .const import (
TUYA_DISCOVERY_NEW,
TUYA_HA_SIGNAL_UPDATE_ENTITY,
)
from .services import async_setup_services
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
# Suppress logs from the library, it logs unneeded on error
logging.getLogger("tuya_sharing").setLevel(logging.CRITICAL)
@@ -45,6 +49,12 @@ class HomeAssistantTuyaData(NamedTuple):
listener: SharingDeviceListener
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Tuya component."""
async_setup_services(hass)
return True
async def async_setup_entry(hass: HomeAssistant, entry: TuyaConfigEntry) -> bool:
"""Async setup hass config entry."""
if CONF_APP_TYPE in entry.data:

View File

@@ -381,5 +381,10 @@
"default": "mdi:alarm-light"
}
}
},
"services": {
"send_text_command": {
"service": "mdi:pencil"
}
}
}

View File

@@ -0,0 +1,111 @@
"""Support for Tuya services."""
from __future__ import annotations
from typing import TYPE_CHECKING
import voluptuous as vol
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import ATTR_DEVICE_ID
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import config_validation as cv, device_registry as dr
from .const import DOMAIN, LOGGER
if TYPE_CHECKING:
from . import TuyaConfigEntry
SEND_TEXT_COMMAND = "send_text_command"
ATTR_CODE = "code"
ATTR_VALUE = "value"
SEND_TEXT_COMMAND_SCHEMA = vol.Schema(
{
vol.Required(ATTR_DEVICE_ID): cv.string,
vol.Required(ATTR_CODE): cv.string,
vol.Required(ATTR_VALUE): cv.string,
}
)
@callback
def _async_get_device(
call: ServiceCall,
) -> tuple[str, TuyaConfigEntry]:
"""Get the tuya device ID and config entry related to a service call."""
device_registry = dr.async_get(call.hass)
device_id = call.data[ATTR_DEVICE_ID]
if (device_entry := device_registry.async_get(device_id)) is None:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="invalid_device_id",
translation_placeholders={"device_id": device_id},
)
entry: TuyaConfigEntry | None
for entry_id in device_entry.config_entries:
if (entry := call.hass.config_entries.async_get_entry(entry_id)) is None:
continue
if entry.domain == DOMAIN:
if entry.state is not ConfigEntryState.LOADED:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="entry_not_loaded",
translation_placeholders={"entry": entry.title},
)
tuya_device_id = next(
(
key
for key in entry.runtime_data.manager.device_map
if (DOMAIN, key) in device_entry.identifiers
),
None,
)
if tuya_device_id is None:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="tuya_device_not_found",
translation_placeholders={"device_id": device_entry.id},
)
return tuya_device_id, entry
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="config_entry_not_found",
translation_placeholders={"device_id": device_id},
)
async def _async_send_device_command(call: ServiceCall) -> None:
"""Send Tuya device command."""
tuya_device_id, config_entry = _async_get_device(call)
commands = [
{
"code": call.data[ATTR_CODE],
"value": call.data[ATTR_VALUE],
}
]
LOGGER.debug("Sending commands for device %s: %s", tuya_device_id, commands)
await call.hass.async_add_executor_job(
config_entry.runtime_data.manager.send_commands,
tuya_device_id,
commands,
)
@callback
def async_setup_services(hass: HomeAssistant) -> None:
"""Home Assistant services."""
hass.services.async_register(
DOMAIN,
SEND_TEXT_COMMAND,
_async_send_device_command,
schema=SEND_TEXT_COMMAND_SCHEMA,
)

View File

@@ -0,0 +1,17 @@
send_text_command:
fields:
device_id:
required: true
selector:
device:
integration: tuya
code:
required: true
example: "up_down"
selector:
text:
value:
required: true
example: "up"
selector:
text:

View File

@@ -1002,5 +1002,25 @@
"action_dpcode_not_found": {
"message": "Unable to process action as the device does not provide a corresponding function code (expected one of {expected} in {available})."
}
},
"services": {
"send_text_command": {
"name": "Send text command",
"description": "Send a text command to a device.",
"fields": {
"device_id": {
"name": "Device",
"description": "The ID of the device to send the command to."
},
"code": {
"name": "Code",
"description": "The name of the data point."
},
"value": {
"name": "Value",
"description": "The new value of the data point."
}
}
}
}
}

View File

@@ -18,7 +18,7 @@ async def test_device_registry(
hass: HomeAssistant,
mock_manager: Manager,
mock_config_entry: MockConfigEntry,
mock_devices: CustomerDevice,
mock_devices: list[CustomerDevice],
device_registry: dr.DeviceRegistry,
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion,

View File

@@ -0,0 +1,63 @@
"""Test Tuya initialization."""
from __future__ import annotations
import pytest
from tuya_sharing import CustomerDevice, Manager
from homeassistant.components.tuya.const import DOMAIN
from homeassistant.components.tuya.services import SEND_TEXT_COMMAND
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from . import initialize_entry
from tests.common import MockConfigEntry
async def test_setup_services(
hass: HomeAssistant,
mock_manager: Manager,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test setup of Tuya services."""
await initialize_entry(hass, mock_manager, mock_config_entry, [])
assert (services := hass.services.async_services_for_domain(DOMAIN))
assert SEND_TEXT_COMMAND in services
@pytest.mark.parametrize(
"mock_device_code",
["sjz_ftbc8rp8ipksdfpv"],
)
async def test_send_device_command(
hass: HomeAssistant,
mock_manager: Manager,
mock_config_entry: MockConfigEntry,
mock_device: CustomerDevice,
device_registry: dr.DeviceRegistry,
) -> None:
"""Validate send_device_command."""
await initialize_entry(hass, mock_manager, mock_config_entry, mock_device)
device_entry = device_registry.async_get_device(
identifiers={(DOMAIN, "vpfdskpi8pr8cbtfzjs")}
)
assert device_entry is not None
await hass.services.async_call(
DOMAIN,
SEND_TEXT_COMMAND,
{
"device_id": device_entry.id,
"code": "up_down",
"value": "up",
},
blocking=True,
)
mock_manager.send_commands.assert_called_once_with(
"vpfdskpi8pr8cbtfzjs", [{"code": "up_down", "value": "up"}]
)