From 9b00e0cb7a646a1c1207622d104359677959405c Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Fri, 17 Sep 2021 15:28:43 +0200 Subject: [PATCH] Rfxtrx device triggers and actions (#47909) * Add helper * Add device actions * Add trigger * Just make use of standard command * Generalize code a bit * Switch tests to currently existing features * Add tests for capabilities * Don't check schema asserted value * Adjust strings somewhat * Directly expose action subtypes * Add a status event test * Switch to modern typing * Drop chime that is now part of command * Adjust strings a bit * Drop ability to set custom value * Adjust changed base schema * Validate triggers * Try fix typing for 3.8 --- .../components/rfxtrx/device_action.py | 99 +++++++++ .../components/rfxtrx/device_trigger.py | 110 ++++++++++ homeassistant/components/rfxtrx/helpers.py | 22 ++ homeassistant/components/rfxtrx/strings.json | 10 + .../components/rfxtrx/translations/en.json | 15 +- tests/components/rfxtrx/test_device_action.py | 206 ++++++++++++++++++ .../components/rfxtrx/test_device_trigger.py | 186 ++++++++++++++++ 7 files changed, 646 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/rfxtrx/device_action.py create mode 100644 homeassistant/components/rfxtrx/device_trigger.py create mode 100644 homeassistant/components/rfxtrx/helpers.py create mode 100644 tests/components/rfxtrx/test_device_action.py create mode 100644 tests/components/rfxtrx/test_device_trigger.py diff --git a/homeassistant/components/rfxtrx/device_action.py b/homeassistant/components/rfxtrx/device_action.py new file mode 100644 index 00000000000..37fb39cb499 --- /dev/null +++ b/homeassistant/components/rfxtrx/device_action.py @@ -0,0 +1,99 @@ +"""Provides device automations for RFXCOM RFXtrx.""" +from __future__ import annotations + +import voluptuous as vol + +from homeassistant.components.device_automation.exceptions import ( + InvalidDeviceAutomationConfig, +) +from homeassistant.const import CONF_DEVICE_ID, CONF_DOMAIN, CONF_TYPE +from homeassistant.core import Context, HomeAssistant +import homeassistant.helpers.config_validation as cv + +from . import DATA_RFXOBJECT, DOMAIN +from .helpers import async_get_device_object + +CONF_DATA = "data" +CONF_SUBTYPE = "subtype" + +ACTION_TYPE_COMMAND = "send_command" +ACTION_TYPE_STATUS = "send_status" + +ACTION_TYPES = { + ACTION_TYPE_COMMAND, + ACTION_TYPE_STATUS, +} + +ACTION_SELECTION = { + ACTION_TYPE_COMMAND: "COMMANDS", + ACTION_TYPE_STATUS: "STATUS", +} + +ACTION_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( + { + vol.Required(CONF_TYPE): vol.In(ACTION_TYPES), + vol.Required(CONF_SUBTYPE): str, + } +) + + +async def async_get_actions(hass: HomeAssistant, device_id: str) -> list[dict]: + """List device actions for RFXCOM RFXtrx devices.""" + + try: + device = async_get_device_object(hass, device_id) + except ValueError: + return [] + + actions = [] + for action_type in ACTION_TYPES: + if hasattr(device, action_type): + values = getattr(device, ACTION_SELECTION[action_type], {}) + for value in values.values(): + actions.append( + { + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_TYPE: action_type, + CONF_SUBTYPE: value, + } + ) + + return actions + + +def _get_commands(hass, device_id, action_type): + device = async_get_device_object(hass, device_id) + send_fun = getattr(device, action_type) + commands = getattr(device, ACTION_SELECTION[action_type], {}) + return commands, send_fun + + +async def async_validate_action_config(hass, config): + """Validate config.""" + config = ACTION_SCHEMA(config) + commands, _ = _get_commands(hass, config[CONF_DEVICE_ID], config[CONF_TYPE]) + sub_type = config[CONF_SUBTYPE] + + if sub_type not in commands.values(): + raise InvalidDeviceAutomationConfig( + f"Subtype {sub_type} not found in device commands {commands}" + ) + + return config + + +async def async_call_action_from_config( + hass: HomeAssistant, config: dict, variables: dict, context: Context | None +) -> None: + """Execute a device action.""" + config = ACTION_SCHEMA(config) + + rfx = hass.data[DOMAIN][DATA_RFXOBJECT] + commands, send_fun = _get_commands(hass, config[CONF_DEVICE_ID], config[CONF_TYPE]) + sub_type = config[CONF_SUBTYPE] + + for key, value in commands.items(): + if value == sub_type: + await hass.async_add_executor_job(send_fun, rfx.transport, key) + return diff --git a/homeassistant/components/rfxtrx/device_trigger.py b/homeassistant/components/rfxtrx/device_trigger.py new file mode 100644 index 00000000000..55430ad3fe2 --- /dev/null +++ b/homeassistant/components/rfxtrx/device_trigger.py @@ -0,0 +1,110 @@ +"""Provides device automations for RFXCOM RFXtrx.""" +from __future__ import annotations + +import voluptuous as vol + +from homeassistant.components.automation import AutomationActionType +from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA +from homeassistant.components.device_automation.exceptions import ( + InvalidDeviceAutomationConfig, +) +from homeassistant.components.homeassistant.triggers import event as event_trigger +from homeassistant.components.rfxtrx.const import EVENT_RFXTRX_EVENT +from homeassistant.const import ( + ATTR_DEVICE_ID, + CONF_DEVICE_ID, + CONF_DOMAIN, + CONF_PLATFORM, + CONF_TYPE, +) +from homeassistant.core import CALLBACK_TYPE, HomeAssistant +from homeassistant.helpers.typing import ConfigType + +from . import DOMAIN +from .helpers import async_get_device_object + +CONF_SUBTYPE = "subtype" + +CONF_TYPE_COMMAND = "command" +CONF_TYPE_STATUS = "status" + +TRIGGER_SELECTION = { + CONF_TYPE_COMMAND: "COMMANDS", + CONF_TYPE_STATUS: "STATUS", +} +TRIGGER_TYPES = [ + CONF_TYPE_COMMAND, + CONF_TYPE_STATUS, +] +TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( + { + vol.Required(CONF_TYPE): vol.In(TRIGGER_TYPES), + vol.Required(CONF_SUBTYPE): str, + } +) + + +async def async_get_triggers(hass: HomeAssistant, device_id: str) -> list[dict]: + """List device triggers for RFXCOM RFXtrx devices.""" + device = async_get_device_object(hass, device_id) + + triggers = [] + for conf_type in TRIGGER_TYPES: + data = getattr(device, TRIGGER_SELECTION[conf_type], {}) + for command in data.values(): + triggers.append( + { + CONF_PLATFORM: "device", + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_TYPE: conf_type, + CONF_SUBTYPE: command, + } + ) + return triggers + + +async def async_validate_trigger_config(hass, config): + """Validate config.""" + config = TRIGGER_SCHEMA(config) + + device = async_get_device_object(hass, config[CONF_DEVICE_ID]) + + action_type = config[CONF_TYPE] + sub_type = config[CONF_SUBTYPE] + commands = getattr(device, TRIGGER_SELECTION[action_type], {}) + if config[CONF_SUBTYPE] not in commands.values(): + raise InvalidDeviceAutomationConfig( + f"Subtype {sub_type} not found in device triggers {commands}" + ) + + return config + + +async def async_attach_trigger( + hass: HomeAssistant, + config: ConfigType, + action: AutomationActionType, + automation_info: dict, +) -> CALLBACK_TYPE: + """Attach a trigger.""" + config = TRIGGER_SCHEMA(config) + + event_data = {ATTR_DEVICE_ID: config[CONF_DEVICE_ID]} + + if config[CONF_TYPE] == CONF_TYPE_COMMAND: + event_data["values"] = {"Command": config[CONF_SUBTYPE]} + elif config[CONF_TYPE] == CONF_TYPE_STATUS: + event_data["values"] = {"Status": config[CONF_SUBTYPE]} + + event_config = event_trigger.TRIGGER_SCHEMA( + { + event_trigger.CONF_PLATFORM: "event", + event_trigger.CONF_EVENT_TYPE: EVENT_RFXTRX_EVENT, + event_trigger.CONF_EVENT_DATA: event_data, + } + ) + + return await event_trigger.async_attach_trigger( + hass, event_config, action, automation_info, platform_type="device" + ) diff --git a/homeassistant/components/rfxtrx/helpers.py b/homeassistant/components/rfxtrx/helpers.py new file mode 100644 index 00000000000..ad7d049fb4c --- /dev/null +++ b/homeassistant/components/rfxtrx/helpers.py @@ -0,0 +1,22 @@ +"""Provides helpers for RFXtrx.""" + + +from RFXtrx import get_device + +from homeassistant.core import callback +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.typing import HomeAssistantType + + +@callback +def async_get_device_object(hass: HomeAssistantType, device_id): + """Get a device for the given device registry id.""" + device_registry = dr.async_get(hass) + registry_device = device_registry.async_get(device_id) + if registry_device is None: + raise ValueError(f"Device {device_id} not found") + + device_tuple = list(list(registry_device.identifiers)[0]) + return get_device( + int(device_tuple[1], 16), int(device_tuple[2], 16), device_tuple[3] + ) diff --git a/homeassistant/components/rfxtrx/strings.json b/homeassistant/components/rfxtrx/strings.json index c89fcddb002..75c0de88f13 100644 --- a/homeassistant/components/rfxtrx/strings.json +++ b/homeassistant/components/rfxtrx/strings.json @@ -70,5 +70,15 @@ "invalid_input_off_delay": "Invalid input for off delay", "unknown": "[%key:common::config_flow::error::unknown%]" } + }, + "device_automation": { + "action_type": { + "send_status": "Send status update: {subtype}", + "send_command": "Send command: {subtype}" + }, + "trigger_type": { + "status": "Received status: {subtype}", + "command": "Received command: {subtype}" + } } } diff --git a/homeassistant/components/rfxtrx/translations/en.json b/homeassistant/components/rfxtrx/translations/en.json index 5e3f551e0cf..69be3726865 100644 --- a/homeassistant/components/rfxtrx/translations/en.json +++ b/homeassistant/components/rfxtrx/translations/en.json @@ -70,5 +70,16 @@ "title": "Configure device options" } } - } -} \ No newline at end of file + }, + "device_automation": { + "action_type": { + "send_status": "Send status update: {subtype}", + "send_command": "Send command: {subtype}" + }, + "trigger_type": { + "status": "Received status: {subtype}", + "command": "Received command: {subtype}" + } + }, + "title": "Rfxtrx" +} diff --git a/tests/components/rfxtrx/test_device_action.py b/tests/components/rfxtrx/test_device_action.py new file mode 100644 index 00000000000..cedf2082fb2 --- /dev/null +++ b/tests/components/rfxtrx/test_device_action.py @@ -0,0 +1,206 @@ +"""The tests for RFXCOM RFXtrx device actions.""" +from __future__ import annotations + +from typing import Any, NamedTuple + +import RFXtrx +import pytest + +import homeassistant.components.automation as automation +from homeassistant.components.rfxtrx import DOMAIN +from homeassistant.helpers.device_registry import DeviceRegistry +from homeassistant.setup import async_setup_component + +from tests.common import ( + MockConfigEntry, + assert_lists_same, + async_get_device_automations, + async_mock_service, + mock_device_registry, + mock_registry, +) +from tests.components.rfxtrx.conftest import create_rfx_test_cfg + + +@pytest.fixture(name="device_reg") +def device_reg_fixture(hass): + """Return an empty, loaded, registry.""" + return mock_device_registry(hass) + + +@pytest.fixture(name="entity_reg") +def entity_reg_fixture(hass): + """Return an empty, loaded, registry.""" + return mock_registry(hass) + + +class DeviceTestData(NamedTuple): + """Test data linked to a device.""" + + code: str + device_identifiers: set[tuple[str, str, str, str]] + + +DEVICE_LIGHTING_1 = DeviceTestData("0710002a45050170", {("rfxtrx", "10", "0", "E5")}) + +DEVICE_BLINDS_1 = DeviceTestData( + "09190000009ba8010100", {("rfxtrx", "19", "0", "009ba8:1")} +) + +DEVICE_TEMPHUM_1 = DeviceTestData( + "0a52080705020095220269", {("rfxtrx", "52", "8", "05:02")} +) + + +@pytest.mark.parametrize("device", [DEVICE_LIGHTING_1, DEVICE_TEMPHUM_1]) +async def test_device_test_data(rfxtrx, device: DeviceTestData): + """Verify that our testing data remains correct.""" + pkt: RFXtrx.lowlevel.Packet = RFXtrx.lowlevel.parse(bytearray.fromhex(device.code)) + assert device.device_identifiers == { + ("rfxtrx", f"{pkt.packettype:x}", f"{pkt.subtype:x}", pkt.id_string) + } + + +async def setup_entry(hass, devices): + """Construct a config setup.""" + entry_data = create_rfx_test_cfg(devices=devices) + mock_entry = MockConfigEntry(domain="rfxtrx", unique_id=DOMAIN, data=entry_data) + + mock_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() + await hass.async_start() + + +def _get_expected_actions(data): + for value in data.values(): + yield {"type": "send_command", "subtype": value} + + +@pytest.mark.parametrize( + "device,expected", + [ + [ + DEVICE_LIGHTING_1, + list(_get_expected_actions(RFXtrx.lowlevel.Lighting1.COMMANDS)), + ], + [ + DEVICE_BLINDS_1, + list(_get_expected_actions(RFXtrx.lowlevel.RollerTrol.COMMANDS)), + ], + [DEVICE_TEMPHUM_1, []], + ], +) +async def test_get_actions(hass, device_reg: DeviceRegistry, device, expected): + """Test we get the expected actions from a rfxtrx.""" + await setup_entry(hass, {device.code: {"signal_repetitions": 1}}) + + device_entry = device_reg.async_get_device(device.device_identifiers, set()) + assert device_entry + + actions = await async_get_device_automations(hass, "action", device_entry.id) + actions = [action for action in actions if action["domain"] == DOMAIN] + + expected_actions = [ + {"domain": DOMAIN, "device_id": device_entry.id, **action_type} + for action_type in expected + ] + + assert_lists_same(actions, expected_actions) + + +@pytest.mark.parametrize( + "device,config,expected", + [ + [ + DEVICE_LIGHTING_1, + {"type": "send_command", "subtype": "On"}, + "0710000045050100", + ], + [ + DEVICE_LIGHTING_1, + {"type": "send_command", "subtype": "Off"}, + "0710000045050000", + ], + [ + DEVICE_BLINDS_1, + {"type": "send_command", "subtype": "Stop"}, + "09190000009ba8010200", + ], + ], +) +async def test_action( + hass, device_reg: DeviceRegistry, rfxtrx: RFXtrx.Connect, device, config, expected +): + """Test for actions.""" + + await setup_entry(hass, {device.code: {"signal_repetitions": 1}}) + + device_entry = device_reg.async_get_device(device.device_identifiers, set()) + assert device_entry + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "event", + "event_type": "test_event", + }, + "action": { + "domain": DOMAIN, + "device_id": device_entry.id, + **config, + }, + }, + ] + }, + ) + + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + + rfxtrx.transport.send.assert_called_once_with(bytearray.fromhex(expected)) + + +async def test_invalid_action(hass, device_reg: DeviceRegistry): + """Test for invalid actions.""" + device = DEVICE_LIGHTING_1 + notification_calls = async_mock_service(hass, "persistent_notification", "create") + + await setup_entry(hass, {device.code: {"signal_repetitions": 1}}) + + device_identifers: Any = device.device_identifiers + device_entry = device_reg.async_get_device(device_identifers, set()) + assert device_entry + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "event", + "event_type": "test_event", + }, + "action": { + "domain": DOMAIN, + "device_id": device_entry.id, + "type": "send_command", + "subtype": "invalid", + }, + }, + ] + }, + ) + await hass.async_block_till_done() + + assert len(notification_calls) == 1 + assert ( + "The following integrations and platforms could not be set up" + in notification_calls[0].data["message"] + ) diff --git a/tests/components/rfxtrx/test_device_trigger.py b/tests/components/rfxtrx/test_device_trigger.py new file mode 100644 index 00000000000..9ac2c7e9819 --- /dev/null +++ b/tests/components/rfxtrx/test_device_trigger.py @@ -0,0 +1,186 @@ +"""The tests for RFXCOM RFXtrx device triggers.""" +from __future__ import annotations + +from typing import Any, NamedTuple + +import pytest + +import homeassistant.components.automation as automation +from homeassistant.components.rfxtrx import DOMAIN +from homeassistant.helpers.device_registry import DeviceRegistry +from homeassistant.setup import async_setup_component + +from tests.common import ( + MockConfigEntry, + assert_lists_same, + async_get_device_automations, + async_mock_service, + mock_device_registry, +) +from tests.components.rfxtrx.conftest import create_rfx_test_cfg + + +class EventTestData(NamedTuple): + """Test data linked to a device.""" + + code: str + device_identifiers: set[tuple[str, str, str, str]] + type: str + subtype: str + + +DEVICE_LIGHTING_1 = {("rfxtrx", "10", "0", "E5")} +EVENT_LIGHTING_1 = EventTestData("0710002a45050170", DEVICE_LIGHTING_1, "command", "On") + +DEVICE_ROLLERTROL_1 = {("rfxtrx", "19", "0", "009ba8:1")} +EVENT_ROLLERTROL_1 = EventTestData( + "09190000009ba8010100", DEVICE_ROLLERTROL_1, "command", "Down" +) + +DEVICE_FIREALARM_1 = {("rfxtrx", "20", "3", "a10900:32")} +EVENT_FIREALARM_1 = EventTestData( + "08200300a109000670", DEVICE_FIREALARM_1, "status", "Panic" +) + + +@pytest.fixture(name="device_reg") +def device_reg_fixture(hass): + """Return an empty, loaded, registry.""" + return mock_device_registry(hass) + + +async def setup_entry(hass, devices): + """Construct a config setup.""" + entry_data = create_rfx_test_cfg(devices=devices) + mock_entry = MockConfigEntry(domain="rfxtrx", unique_id=DOMAIN, data=entry_data) + + mock_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() + await hass.async_start() + + +@pytest.mark.parametrize( + "event,expected", + [ + [ + EVENT_LIGHTING_1, + [ + {"type": "command", "subtype": subtype} + for subtype in [ + "Off", + "On", + "Dim", + "Bright", + "All/group Off", + "All/group On", + "Chime", + "Illegal command", + ] + ], + ] + ], +) +async def test_get_triggers(hass, device_reg, event: EventTestData, expected): + """Test we get the expected triggers from a rfxtrx.""" + await setup_entry(hass, {event.code: {"signal_repetitions": 1}}) + + device_entry = device_reg.async_get_device(event.device_identifiers, set()) + + expected_triggers = [ + {"domain": DOMAIN, "device_id": device_entry.id, "platform": "device", **expect} + for expect in expected + ] + + triggers = await async_get_device_automations(hass, "trigger", device_entry.id) + triggers = [value for value in triggers if value["domain"] == "rfxtrx"] + assert_lists_same(triggers, expected_triggers) + + +@pytest.mark.parametrize( + "event", + [ + EVENT_LIGHTING_1, + EVENT_ROLLERTROL_1, + EVENT_FIREALARM_1, + ], +) +async def test_firing_event(hass, device_reg: DeviceRegistry, rfxtrx, event): + """Test for turn_on and turn_off triggers firing.""" + + await setup_entry(hass, {event.code: {"fire_event": True, "signal_repetitions": 1}}) + + device_entry = device_reg.async_get_device(event.device_identifiers, set()) + assert device_entry + + calls = async_mock_service(hass, "test", "automation") + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": device_entry.id, + "type": event.type, + "subtype": event.subtype, + }, + "action": { + "service": "test.automation", + "data_template": {"some": ("{{trigger.platform}}")}, + }, + }, + ] + }, + ) + await hass.async_block_till_done() + + await rfxtrx.signal(event.code) + + assert len(calls) == 1 + assert calls[0].data["some"] == "device" + + +async def test_invalid_trigger(hass, device_reg: DeviceRegistry): + """Test for invalid actions.""" + event = EVENT_LIGHTING_1 + notification_calls = async_mock_service(hass, "persistent_notification", "create") + + await setup_entry(hass, {event.code: {"fire_event": True, "signal_repetitions": 1}}) + + device_identifers: Any = event.device_identifiers + device_entry = device_reg.async_get_device(device_identifers, set()) + assert device_entry + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": device_entry.id, + "type": event.type, + "subtype": "invalid", + }, + "action": { + "service": "test.automation", + "data_template": {"some": ("{{trigger.platform}}")}, + }, + }, + ] + }, + ) + await hass.async_block_till_done() + + assert len(notification_calls) == 1 + assert ( + "The following integrations and platforms could not be set up" + in notification_calls[0].data["message"] + )