Skip to content

Commit

Permalink
Add send_command service (#124)
Browse files Browse the repository at this point in the history
Add send_command action / service

 * Re-create the service, send_command

Sem-Ver: feature
  • Loading branch information
Expl0dingBanana authored Jan 13, 2025
1 parent 3b7be4a commit 7118346
Show file tree
Hide file tree
Showing 9 changed files with 243 additions and 12 deletions.
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion custom_components/hubspace/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
DOMAIN,
POLLING_TIME_STR,
)
from .services import async_register_services

_LOGGER = logging.getLogger(__name__)

Expand All @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion custom_components/hubspace/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
79 changes: 79 additions & 0 deletions custom_components/hubspace/services.py
Original file line number Diff line number Diff line change
@@ -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
12 changes: 11 additions & 1 deletion custom_components/hubspace/services.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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: [email protected]
value:
name: value
description: value you want to send
Expand Down
20 changes: 12 additions & 8 deletions custom_components/hubspace/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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%]"
}
}
}
Expand Down
4 changes: 4 additions & 0 deletions custom_components/hubspace/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -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
129 changes: 129 additions & 0 deletions tests/test_services.py
Original file line number Diff line number Diff line change
@@ -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

0 comments on commit 7118346

Please sign in to comment.