From 711834680bd1f22950d9fd4b7f55db8ba5d9ec8b Mon Sep 17 00:00:00 2001 From: Christopher Dohmen Date: Mon, 13 Jan 2025 06:26:37 -0500 Subject: [PATCH] Add send_command service (#124) Add send_command action / service * Re-create the service, send_command Sem-Ver: feature --- README.md | 4 + custom_components/hubspace/__init__.py | 3 +- custom_components/hubspace/manifest.json | 2 +- custom_components/hubspace/services.py | 79 +++++++++++ custom_components/hubspace/services.yaml | 12 +- custom_components/hubspace/strings.json | 20 +-- .../hubspace/translations/en.json | 4 + setup.cfg | 2 +- tests/test_services.py | 129 ++++++++++++++++++ 9 files changed, 243 insertions(+), 12 deletions(-) create mode 100644 custom_components/hubspace/services.py create mode 100644 tests/test_services.py diff --git a/README.md b/README.md index b2e7dab..ed27545 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,10 @@ are as follows: ## Changelog + * 4.1.0 + + * Re-implement the action / service send_command + * 4.0.1 * Fixed an issue where fans could cause an UHE if they did not support diff --git a/custom_components/hubspace/__init__.py b/custom_components/hubspace/__init__.py index 96dda2a..106dd9b 100644 --- a/custom_components/hubspace/__init__.py +++ b/custom_components/hubspace/__init__.py @@ -14,6 +14,7 @@ DOMAIN, POLLING_TIME_STR, ) +from .services import async_register_services _LOGGER = logging.getLogger(__name__) @@ -24,7 +25,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if not await bridge.async_initialize_bridge(): return False - # @TODO - Actions / Services + async_register_services(hass) device_registry = dr.async_get(hass) device_registry.async_get_or_create( diff --git a/custom_components/hubspace/manifest.json b/custom_components/hubspace/manifest.json index b143429..eee255f 100644 --- a/custom_components/hubspace/manifest.json +++ b/custom_components/hubspace/manifest.json @@ -9,5 +9,5 @@ "issue_tracker": "https://github.com/jdeath/Hubspace-Homeassistant/issues", "loggers": ["aiohubspace"], "requirements": ["aiohubspace==0.6.2", "aiofiles==24.1.0"], - "version": "4.0.1" + "version": "4.1.0" } diff --git a/custom_components/hubspace/services.py b/custom_components/hubspace/services.py new file mode 100644 index 0000000..e864729 --- /dev/null +++ b/custom_components/hubspace/services.py @@ -0,0 +1,79 @@ +import asyncio +import logging +from typing import Final + +import homeassistant.helpers.config_validation as cv +import voluptuous as vol +from homeassistant.const import CONF_USERNAME +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.service import verify_domain_control + +from .bridge import HubspaceBridge +from .const import DOMAIN + +SERVICE_SEND_COMMAND = "send_command" + +SERVICE_SEND_COMMAND_FUNC_CLASS: Final[str] = "function_class" +SERVICE_SEND_COMMAND_FUNC_INSTANCE: Final[str] = "function_instance" +SERVICE_SEND_COMMAND_VALUE: Final[str] = "value" +SERVICE_SEND_COMMAND_ACCOUNT: Final[str] = "account" + +LOGGER = logging.getLogger(__name__) + + +def async_register_services(hass: HomeAssistant) -> None: + """Register services for Hubspace integration.""" + + async def send_command(call: ServiceCall, skip_reload=True) -> None: + states: list[dict] = [] + states.append( + { + "value": call.data.get(SERVICE_SEND_COMMAND_VALUE), + "functionClass": call.data.get(SERVICE_SEND_COMMAND_FUNC_CLASS), + "functionInstance": call.data.get(SERVICE_SEND_COMMAND_FUNC_INSTANCE), + } + ) + entity_reg = er.async_get(hass) + tasks = [] + account = call.data.get("account") + for entity_name in call.data.get("entity_id", []): + entity = entity_reg.async_get(entity_name) + bridge = await find_bridge(hass, account) + if bridge: + tasks.append(bridge.api.send_service_request(entity.unique_id, states)) + else: + LOGGER.warning("No bridge using account %s", account) + return + await asyncio.gather(*tasks) + + def optional(value): + if value is None: + return value + return cv.string(value) + + if not hass.services.has_service(DOMAIN, SERVICE_SEND_COMMAND): + hass.services.async_register( + DOMAIN, + SERVICE_SEND_COMMAND, + verify_domain_control(hass, DOMAIN)(send_command), + schema=vol.Schema( + { + vol.Required("entity_id"): cv.entity_ids, + vol.Required(SERVICE_SEND_COMMAND_FUNC_CLASS): cv.string, + vol.Required(SERVICE_SEND_COMMAND_VALUE): cv.string, + vol.Optional(SERVICE_SEND_COMMAND_FUNC_INSTANCE): optional, + vol.Optional(SERVICE_SEND_COMMAND_ACCOUNT): optional, + } + ), + ) + + +async def find_bridge(hass: HomeAssistant, username: str) -> HubspaceBridge | None: + """Find the bridge for the given username""" + for bridge in hass.data[DOMAIN].values(): + if username is None: + return bridge + if bridge.config_entry.data[CONF_USERNAME] == username: + return bridge + return None diff --git a/custom_components/hubspace/services.yaml b/custom_components/hubspace/services.yaml index a6d6e69..3d434b7 100644 --- a/custom_components/hubspace/services.yaml +++ b/custom_components/hubspace/services.yaml @@ -3,8 +3,18 @@ send_command: target: entity: integration: hubspace - domain: light + domain: + - fan + - light + - lock + - switch + - valve fields: + account: + name: account + description: Username associated with the device. If not present, it will use the first Hubspace instance + required: false + example: your.email@gmail.com value: name: value description: value you want to send diff --git a/custom_components/hubspace/strings.json b/custom_components/hubspace/strings.json index 9fbfda4..f6f3f9a 100644 --- a/custom_components/hubspace/strings.json +++ b/custom_components/hubspace/strings.json @@ -32,20 +32,24 @@ }, "services": { "send_command": { - "name": "Send Command", - "description": "Sends a custom hubspace API command.", + "name": "[%key:component::hubspace::services::send_command::name%]", + "description": "[%key:component::hubspace::services::send_command::description%]", "fields": { "value": { - "name": "Value", - "description": "The value you want to send" + "name": "[%key:component::hubspace::services::send_command::fields::value::name%]", + "description": "[%key:component::hubspace::services::send_command::fields::value::description%]" }, "function_class": { - "name": "Function Class", - "description": "functionClass you want to send" + "name": "[%key:component::hubspace::services::send_command::fields::function_class::name%]", + "description": "[%key:component::hubspace::services::send_command::fields::function_class::description%]" }, "function_instance": { - "name": "Function Instance", - "description": "functionInstance you want to send" + "name": "[%key:component::hubspace::services::send_command::fields::function_instance::name%]", + "description": "[%key:component::hubspace::services::send_command::fields::function_instance::description%]" + }, + "account": { + "name": "[%key:component::hubspace::services::send_command::fields::account::name%]", + "description": "[%key:component::hubspace::services::send_command::fields::account::description%]" } } } diff --git a/custom_components/hubspace/translations/en.json b/custom_components/hubspace/translations/en.json index 290d64a..a5e00cf 100644 --- a/custom_components/hubspace/translations/en.json +++ b/custom_components/hubspace/translations/en.json @@ -57,6 +57,10 @@ "function_instance": { "name": "Function Instance", "description": "functionInstance you want to send" + }, + "account": { + "name": "Account", + "description": "Hubspace account that contains the device. Optional" } } } diff --git a/setup.cfg b/setup.cfg index 93177c5..e67e524 100644 --- a/setup.cfg +++ b/setup.cfg @@ -3,4 +3,4 @@ max-line-length = 99 [tool:pytest] asyncio_mode = auto -addopts = --cov --cov-report term-missing +addopts = --cov=custom_components.hubspace --cov-report term-missing diff --git a/tests/test_services.py b/tests/test_services.py new file mode 100644 index 0000000..2f98cdf --- /dev/null +++ b/tests/test_services.py @@ -0,0 +1,129 @@ +import pytest +import voluptuous +from aiohubspace.v1.device import HubspaceState + +from custom_components.hubspace import const, services + +from .utils import create_devices_from_data, modify_state + +fan_zandra = create_devices_from_data("fan-ZandraFan.json") +fan_zandra_light = fan_zandra[1] +fan_zandra_light_id = "light.friendly_device_2_light" + + +@pytest.fixture +async def mocked_entity(mocked_entry): + hass, entry, bridge = mocked_entry + await bridge.lights.initialize_elem(fan_zandra_light) + await bridge.devices.initialize_elem(fan_zandra[2]) + bridge.add_device(fan_zandra_light.id, bridge.lights) + bridge.add_device(fan_zandra[2].id, bridge.devices) + bridge.lights._initialized = True + bridge.devices._initialized = True + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + yield hass, entry, bridge + await bridge.close() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "account, entity_id, error_entity, error_bridge", + [ + # Use any bridge + ( + None, + fan_zandra_light_id, + None, + None, + ), + # Use bridge that has an account match + ("username", fan_zandra_light_id, None, None), + # Invalid entity + ("username", "i dont exist", True, None), + # No bridge that uses username + ("username2", fan_zandra_light_id, None, True), + ], +) +async def test_service_valid_no_username( + account, entity_id, error_entity, error_bridge, mocked_entity, caplog +): + hass, entry, bridge = mocked_entity + assert hass.states.get(fan_zandra_light_id).state == "on" + if not error_entity and not error_bridge: + await hass.services.async_call( + const.DOMAIN, + services.SERVICE_SEND_COMMAND, + service_data={ + "entity_id": [entity_id], + "value": "off", + "function_class": "power", + "function_instance": "light-power", + "account": account, + }, + blocking=True, + ) + await hass.async_block_till_done() + update_call = bridge.request.call_args_list[-1] + assert update_call.args[0] == "put" + payload = update_call.kwargs["json"] + assert payload["metadeviceId"] == fan_zandra_light.id + assert payload["values"] == [ + { + "functionClass": "power", + "functionInstance": "light-power", + "value": "off", + } + ] + # Now generate update event by emitting the json we've sent as incoming event + light_update = create_devices_from_data("fan-ZandraFan.json")[1] + modify_state( + light_update, + HubspaceState( + functionClass="power", + functionInstance="light-power", + value="off", + ), + ) + event = { + "type": "update", + "device_id": light_update.id, + "device": light_update, + "force_forward": True, + } + bridge.emit_event("update", event) + await hass.async_block_till_done() + assert hass.states.get(fan_zandra_light_id).state == "off" + else: + bridge.request.assert_not_called() + if error_entity: + with pytest.raises(voluptuous.error.MultipleInvalid): + await hass.services.async_call( + const.DOMAIN, + services.SERVICE_SEND_COMMAND, + service_data={ + "entity_id": [entity_id], + "value": "off", + "function_class": "power", + "function_instance": "light-power", + "account": account, + }, + blocking=True, + ) + await hass.async_block_till_done() + else: + await hass.services.async_call( + const.DOMAIN, + services.SERVICE_SEND_COMMAND, + service_data={ + "entity_id": [entity_id], + "value": "off", + "function_class": "power", + "function_instance": "light-power", + "account": account, + }, + blocking=True, + ) + await hass.async_block_till_done() + if error_bridge: + assert f"No bridge using account {account}" in caplog.text