From 72917f1d2c3a0a476b58f0004827530b0d62171d Mon Sep 17 00:00:00 2001 From: danaues Date: Fri, 1 Jul 2022 11:39:00 -0400 Subject: [PATCH] Lutron caseta ra3keypads (#74217) Co-authored-by: J. Nick Koston --- .../components/lutron_caseta/__init__.py | 31 +++-- .../lutron_caseta/device_trigger.py | 112 ++++++++++++------ .../lutron_caseta/test_device_trigger.py | 81 +++++++++++-- 3 files changed, 163 insertions(+), 61 deletions(-) diff --git a/homeassistant/components/lutron_caseta/__init__.py b/homeassistant/components/lutron_caseta/__init__.py index b4ce82a36c6..f2225900aad 100644 --- a/homeassistant/components/lutron_caseta/__init__.py +++ b/homeassistant/components/lutron_caseta/__init__.py @@ -250,6 +250,18 @@ def _area_and_name_from_name(device_name: str) -> tuple[str, str]: return UNASSIGNED_AREA, device_name +@callback +def async_get_lip_button(device_type: str, leap_button: int) -> int | None: + """Get the LIP button for a given LEAP button.""" + if ( + lip_buttons_name_to_num := DEVICE_TYPE_SUBTYPE_MAP_TO_LIP.get(device_type) + ) is None or ( + leap_button_num_to_name := LEAP_TO_DEVICE_TYPE_SUBTYPE_MAP.get(device_type) + ) is None: + return None + return lip_buttons_name_to_num[leap_button_num_to_name[leap_button]] + + @callback def _async_subscribe_pico_remote_events( hass: HomeAssistant, @@ -271,21 +283,8 @@ def _async_subscribe_pico_remote_events( type_ = device["type"] area, name = _area_and_name_from_name(device["name"]) - button_number = device["button_number"] - # The original implementation used LIP instead of LEAP - # so we need to convert the button number to maintain compat - sub_type_to_lip_button = DEVICE_TYPE_SUBTYPE_MAP_TO_LIP[type_] - leap_button_to_sub_type = LEAP_TO_DEVICE_TYPE_SUBTYPE_MAP[type_] - if (sub_type := leap_button_to_sub_type.get(button_number)) is None: - _LOGGER.error( - "Unknown LEAP button number %s is not in %s for %s (%s)", - button_number, - leap_button_to_sub_type, - name, - type_, - ) - return - lip_button_number = sub_type_to_lip_button[sub_type] + leap_button_number = device["button_number"] + lip_button_number = async_get_lip_button(type_, leap_button_number) hass_device = dev_reg.async_get_device({(DOMAIN, device["serial"])}) hass.bus.async_fire( @@ -294,7 +293,7 @@ def _async_subscribe_pico_remote_events( ATTR_SERIAL: device["serial"], ATTR_TYPE: type_, ATTR_BUTTON_NUMBER: lip_button_number, - ATTR_LEAP_BUTTON_NUMBER: button_number, + ATTR_LEAP_BUTTON_NUMBER: leap_button_number, ATTR_DEVICE_NAME: name, ATTR_DEVICE_ID: hass_device.id, ATTR_AREA_NAME: area, diff --git a/homeassistant/components/lutron_caseta/device_trigger.py b/homeassistant/components/lutron_caseta/device_trigger.py index e762e79a8d7..dcdf5d584a4 100644 --- a/homeassistant/components/lutron_caseta/device_trigger.py +++ b/homeassistant/components/lutron_caseta/device_trigger.py @@ -27,7 +27,7 @@ from .const import ( ACTION_PRESS, ACTION_RELEASE, ATTR_ACTION, - ATTR_BUTTON_NUMBER, + ATTR_LEAP_BUTTON_NUMBER, ATTR_SERIAL, CONF_SUBTYPE, DOMAIN, @@ -35,6 +35,12 @@ from .const import ( ) from .models import LutronCasetaData + +def _reverse_dict(forward_dict: dict) -> dict: + """Reverse a dictionary.""" + return {v: k for k, v in forward_dict.items()} + + SUPPORTED_INPUTS_EVENTS_TYPES = [ACTION_PRESS, ACTION_RELEASE] LUTRON_BUTTON_TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( @@ -52,9 +58,6 @@ PICO_2_BUTTON_BUTTON_TYPES_TO_LEAP = { "on": 0, "off": 2, } -LEAP_TO_PICO_2_BUTTON_BUTTON_TYPES = { - v: k for k, v in PICO_2_BUTTON_BUTTON_TYPES_TO_LEAP.items() -} PICO_2_BUTTON_TRIGGER_SCHEMA = LUTRON_BUTTON_TRIGGER_SCHEMA.extend( { vol.Required(CONF_SUBTYPE): vol.In(PICO_2_BUTTON_BUTTON_TYPES_TO_LIP), @@ -74,9 +77,6 @@ PICO_2_BUTTON_RAISE_LOWER_BUTTON_TYPES_TO_LEAP = { "raise": 3, "lower": 4, } -LEAP_TO_PICO_2_BUTTON_RAISE_LOWER_BUTTON_TYPES = { - v: k for k, v in PICO_2_BUTTON_RAISE_LOWER_BUTTON_TYPES_TO_LEAP.items() -} PICO_2_BUTTON_RAISE_LOWER_TRIGGER_SCHEMA = LUTRON_BUTTON_TRIGGER_SCHEMA.extend( { vol.Required(CONF_SUBTYPE): vol.In( @@ -96,9 +96,6 @@ PICO_3_BUTTON_BUTTON_TYPES_TO_LEAP = { "stop": 1, "off": 2, } -LEAP_TO_PICO_3_BUTTON_BUTTON_TYPES = { - v: k for k, v in PICO_3_BUTTON_BUTTON_TYPES_TO_LEAP.items() -} PICO_3_BUTTON_TRIGGER_SCHEMA = LUTRON_BUTTON_TRIGGER_SCHEMA.extend( { vol.Required(CONF_SUBTYPE): vol.In(PICO_3_BUTTON_BUTTON_TYPES_TO_LIP), @@ -119,9 +116,6 @@ PICO_3_BUTTON_RAISE_LOWER_BUTTON_TYPES_TO_LEAP = { "raise": 3, "lower": 4, } -LEAP_TO_PICO_3_BUTTON_RAISE_LOWER_BUTTON_TYPES = { - v: k for k, v in PICO_3_BUTTON_RAISE_LOWER_BUTTON_TYPES_TO_LEAP.items() -} PICO_3_BUTTON_RAISE_LOWER_TRIGGER_SCHEMA = LUTRON_BUTTON_TRIGGER_SCHEMA.extend( { vol.Required(CONF_SUBTYPE): vol.In( @@ -186,9 +180,6 @@ PICO_4_BUTTON_SCENE_BUTTON_TYPES_TO_LEAP = { "button_3": 3, "off": 4, } -LEAP_TO_PICO_4_BUTTON_SCENE_BUTTON_TYPES = { - v: k for k, v in PICO_4_BUTTON_SCENE_BUTTON_TYPES_TO_LEAP.items() -} PICO_4_BUTTON_SCENE_TRIGGER_SCHEMA = LUTRON_BUTTON_TRIGGER_SCHEMA.extend( { vol.Required(CONF_SUBTYPE): vol.In(PICO_4_BUTTON_SCENE_BUTTON_TYPES_TO_LIP), @@ -208,9 +199,6 @@ PICO_4_BUTTON_2_GROUP_BUTTON_TYPES_TO_LEAP = { "group_2_button_1": 3, "group_2_button_2": 4, } -LEAP_TO_PICO_4_BUTTON_2_GROUP_BUTTON_TYPES = { - v: k for k, v in PICO_4_BUTTON_2_GROUP_BUTTON_TYPES_TO_LEAP.items() -} PICO_4_BUTTON_2_GROUP_TRIGGER_SCHEMA = LUTRON_BUTTON_TRIGGER_SCHEMA.extend( { vol.Required(CONF_SUBTYPE): vol.In(PICO_4_BUTTON_2_GROUP_BUTTON_TYPES_TO_LIP), @@ -271,15 +259,58 @@ FOUR_GROUP_REMOTE_BUTTON_TYPES_TO_LEAP = { "raise_4": 23, "lower_4": 24, } -LEAP_TO_FOUR_GROUP_REMOTE_BUTTON_TYPES = { - v: k for k, v in FOUR_GROUP_REMOTE_BUTTON_TYPES_TO_LEAP.items() -} FOUR_GROUP_REMOTE_TRIGGER_SCHEMA = LUTRON_BUTTON_TRIGGER_SCHEMA.extend( { vol.Required(CONF_SUBTYPE): vol.In(FOUR_GROUP_REMOTE_BUTTON_TYPES_TO_LIP), } ) + +SUNNATA_KEYPAD_2_BUTTON_BUTTON_TYPES_TO_LEAP = { + "button_1": 1, + "button_2": 2, +} +SUNNATA_KEYPAD_2_BUTTON_TRIGGER_SCHEMA = LUTRON_BUTTON_TRIGGER_SCHEMA.extend( + { + vol.Required(CONF_SUBTYPE): vol.In( + SUNNATA_KEYPAD_2_BUTTON_BUTTON_TYPES_TO_LEAP + ), + } +) + + +SUNNATA_KEYPAD_3_BUTTON_RAISE_LOWER_BUTTON_TYPES_TO_LEAP = { + "button_1": 1, + "button_2": 2, + "button_3": 3, + "raise": 19, + "lower": 18, +} +SUNNATA_KEYPAD_3_BUTTON_RAISE_LOWER_TRIGGER_SCHEMA = ( + LUTRON_BUTTON_TRIGGER_SCHEMA.extend( + { + vol.Required(CONF_SUBTYPE): vol.In( + SUNNATA_KEYPAD_3_BUTTON_RAISE_LOWER_BUTTON_TYPES_TO_LEAP + ), + } + ) +) + +SUNNATA_KEYPAD_4_BUTTON_BUTTON_TYPES_TO_LEAP = { + "button_1": 1, + "button_2": 2, + "button_3": 3, + "button_4": 4, +} +SUNNATA_KEYPAD_4_BUTTON_TRIGGER_SCHEMA = LUTRON_BUTTON_TRIGGER_SCHEMA.extend( + { + vol.Required(CONF_SUBTYPE): vol.In( + SUNNATA_KEYPAD_4_BUTTON_BUTTON_TYPES_TO_LEAP + ), + } +) + + DEVICE_TYPE_SCHEMA_MAP = { "Pico2Button": PICO_2_BUTTON_TRIGGER_SCHEMA, "Pico2ButtonRaiseLower": PICO_2_BUTTON_RAISE_LOWER_TRIGGER_SCHEMA, @@ -290,6 +321,9 @@ DEVICE_TYPE_SCHEMA_MAP = { "Pico4ButtonZone": PICO_4_BUTTON_ZONE_TRIGGER_SCHEMA, "Pico4Button2Group": PICO_4_BUTTON_2_GROUP_TRIGGER_SCHEMA, "FourGroupRemote": FOUR_GROUP_REMOTE_TRIGGER_SCHEMA, + "SunnataKeypad_2Button": SUNNATA_KEYPAD_2_BUTTON_TRIGGER_SCHEMA, + "SunnataKeypad_3ButtonRaiseLower": SUNNATA_KEYPAD_3_BUTTON_RAISE_LOWER_TRIGGER_SCHEMA, + "SunnataKeypad_4Button": SUNNATA_KEYPAD_4_BUTTON_TRIGGER_SCHEMA, } DEVICE_TYPE_SUBTYPE_MAP_TO_LIP = { @@ -304,16 +338,23 @@ DEVICE_TYPE_SUBTYPE_MAP_TO_LIP = { "FourGroupRemote": FOUR_GROUP_REMOTE_BUTTON_TYPES_TO_LIP, } +DEVICE_TYPE_SUBTYPE_MAP_TO_LEAP = { + "Pico2Button": PICO_2_BUTTON_BUTTON_TYPES_TO_LEAP, + "Pico2ButtonRaiseLower": PICO_2_BUTTON_RAISE_LOWER_BUTTON_TYPES_TO_LEAP, + "Pico3Button": PICO_3_BUTTON_BUTTON_TYPES_TO_LEAP, + "Pico3ButtonRaiseLower": PICO_3_BUTTON_RAISE_LOWER_BUTTON_TYPES_TO_LEAP, + "Pico4Button": PICO_4_BUTTON_BUTTON_TYPES_TO_LEAP, + "Pico4ButtonScene": PICO_4_BUTTON_SCENE_BUTTON_TYPES_TO_LEAP, + "Pico4ButtonZone": PICO_4_BUTTON_ZONE_BUTTON_TYPES_TO_LEAP, + "Pico4Button2Group": PICO_4_BUTTON_2_GROUP_BUTTON_TYPES_TO_LEAP, + "FourGroupRemote": FOUR_GROUP_REMOTE_BUTTON_TYPES_TO_LEAP, + "SunnataKeypad_2Button": SUNNATA_KEYPAD_2_BUTTON_BUTTON_TYPES_TO_LEAP, + "SunnataKeypad_3ButtonRaiseLower": SUNNATA_KEYPAD_3_BUTTON_RAISE_LOWER_BUTTON_TYPES_TO_LEAP, + "SunnataKeypad_4Button": SUNNATA_KEYPAD_4_BUTTON_BUTTON_TYPES_TO_LEAP, +} + LEAP_TO_DEVICE_TYPE_SUBTYPE_MAP = { - "Pico2Button": LEAP_TO_PICO_2_BUTTON_BUTTON_TYPES, - "Pico2ButtonRaiseLower": LEAP_TO_PICO_2_BUTTON_RAISE_LOWER_BUTTON_TYPES, - "Pico3Button": LEAP_TO_PICO_3_BUTTON_BUTTON_TYPES, - "Pico3ButtonRaiseLower": LEAP_TO_PICO_3_BUTTON_RAISE_LOWER_BUTTON_TYPES, - "Pico4Button": LEAP_TO_PICO_4_BUTTON_BUTTON_TYPES, - "Pico4ButtonScene": LEAP_TO_PICO_4_BUTTON_SCENE_BUTTON_TYPES, - "Pico4ButtonZone": LEAP_TO_PICO_4_BUTTON_ZONE_BUTTON_TYPES, - "Pico4Button2Group": LEAP_TO_PICO_4_BUTTON_2_GROUP_BUTTON_TYPES, - "FourGroupRemote": LEAP_TO_FOUR_GROUP_REMOTE_BUTTON_TYPES, + k: _reverse_dict(v) for k, v in DEVICE_TYPE_SUBTYPE_MAP_TO_LEAP.items() } TRIGGER_SCHEMA = vol.Any( @@ -324,6 +365,9 @@ TRIGGER_SCHEMA = vol.Any( PICO_4_BUTTON_ZONE_TRIGGER_SCHEMA, PICO_4_BUTTON_2_GROUP_TRIGGER_SCHEMA, FOUR_GROUP_REMOTE_TRIGGER_SCHEMA, + SUNNATA_KEYPAD_2_BUTTON_TRIGGER_SCHEMA, + SUNNATA_KEYPAD_3_BUTTON_RAISE_LOWER_TRIGGER_SCHEMA, + SUNNATA_KEYPAD_4_BUTTON_TRIGGER_SCHEMA, ) @@ -354,7 +398,7 @@ async def async_get_triggers( if not (device := get_button_device_by_dr_id(hass, device_id)): raise InvalidDeviceAutomationConfig(f"Device not found: {device_id}") - valid_buttons = DEVICE_TYPE_SUBTYPE_MAP_TO_LIP.get(device["type"], {}) + valid_buttons = DEVICE_TYPE_SUBTYPE_MAP_TO_LEAP.get(device["type"], {}) for trigger in SUPPORTED_INPUTS_EVENTS_TYPES: for subtype in valid_buttons: @@ -389,14 +433,14 @@ async def async_attach_trigger( device_type = _device_model_to_type(device.model) _, serial = list(device.identifiers)[0] schema = DEVICE_TYPE_SCHEMA_MAP.get(device_type) - valid_buttons = DEVICE_TYPE_SUBTYPE_MAP_TO_LIP.get(device_type) + valid_buttons = DEVICE_TYPE_SUBTYPE_MAP_TO_LEAP[device_type] config = schema(config) event_config = { event_trigger.CONF_PLATFORM: CONF_EVENT, event_trigger.CONF_EVENT_TYPE: LUTRON_CASETA_BUTTON_EVENT, event_trigger.CONF_EVENT_DATA: { ATTR_SERIAL: serial, - ATTR_BUTTON_NUMBER: valid_buttons[config[CONF_SUBTYPE]], + ATTR_LEAP_BUTTON_NUMBER: valid_buttons[config[CONF_SUBTYPE]], ATTR_ACTION: config[CONF_TYPE], }, } diff --git a/tests/components/lutron_caseta/test_device_trigger.py b/tests/components/lutron_caseta/test_device_trigger.py index bdf1e359673..54cd842f0ee 100644 --- a/tests/components/lutron_caseta/test_device_trigger.py +++ b/tests/components/lutron_caseta/test_device_trigger.py @@ -11,12 +11,12 @@ from homeassistant.components.device_automation.exceptions import ( from homeassistant.components.lutron_caseta import ( ATTR_ACTION, ATTR_AREA_NAME, - ATTR_BUTTON_NUMBER, ATTR_DEVICE_NAME, ATTR_SERIAL, ATTR_TYPE, ) from homeassistant.components.lutron_caseta.const import ( + ATTR_LEAP_BUTTON_NUMBER, DOMAIN, LUTRON_CASETA_BUTTON_EVENT, MANUFACTURER, @@ -51,7 +51,23 @@ MOCK_BUTTON_DEVICES = [ "type": "Pico3ButtonRaiseLower", "model": "PJ2-3BRL-GXX-X01", "serial": 43845548, - } + }, + { + "Name": "Front Steps Sunnata Keypad", + "ID": 3, + "Area": {"Name": "Front Steps"}, + "Buttons": [ + {"Number": 7}, + {"Number": 8}, + {"Number": 9}, + {"Number": 10}, + {"Number": 11}, + ], + "leap_name": "Front Steps_Front Steps Sunnata Keypad", + "type": "SunnataKeypad_3ButtonRaiseLower", + "model": "PJ2-3BRL-GXX-X01", + "serial": 43845547, + }, ] @@ -144,12 +160,11 @@ async def test_get_triggers_for_invalid_device_id(hass, device_reg): async def test_if_fires_on_button_event(hass, calls, device_reg): """Test for press trigger firing.""" - - config_entry_id = await _async_setup_lutron_with_picos(hass, device_reg) - data: LutronCasetaData = hass.data[DOMAIN][config_entry_id] - dr_button_devices = data.button_devices - device_id = list(dr_button_devices)[0] - device = dr_button_devices[device_id] + await _async_setup_lutron_with_picos(hass, device_reg) + device = MOCK_BUTTON_DEVICES[0] + dr = device_registry.async_get(hass) + dr_device = dr.async_get_device(identifiers={(DOMAIN, device["serial"])}) + device_id = dr_device.id assert await async_setup_component( hass, automation.DOMAIN, @@ -175,7 +190,51 @@ async def test_if_fires_on_button_event(hass, calls, device_reg): message = { ATTR_SERIAL: device.get("serial"), ATTR_TYPE: device.get("type"), - ATTR_BUTTON_NUMBER: 2, + ATTR_LEAP_BUTTON_NUMBER: 0, + ATTR_DEVICE_NAME: device["Name"], + ATTR_AREA_NAME: device.get("Area", {}).get("Name"), + ATTR_ACTION: "press", + } + hass.bus.async_fire(LUTRON_CASETA_BUTTON_EVENT, message) + await hass.async_block_till_done() + + assert len(calls) == 1 + assert calls[0].data["some"] == "test_trigger_button_press" + + +async def test_if_fires_on_button_event_without_lip(hass, calls, device_reg): + """Test for press trigger firing on a device that does not support lip.""" + await _async_setup_lutron_with_picos(hass, device_reg) + device = MOCK_BUTTON_DEVICES[1] + dr = device_registry.async_get(hass) + dr_device = dr.async_get_device(identifiers={(DOMAIN, device["serial"])}) + device_id = dr_device.id + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + CONF_PLATFORM: "device", + CONF_DOMAIN: DOMAIN, + CONF_DEVICE_ID: device_id, + CONF_TYPE: "press", + CONF_SUBTYPE: "button_1", + }, + "action": { + "service": "test.automation", + "data_template": {"some": "test_trigger_button_press"}, + }, + }, + ] + }, + ) + + message = { + ATTR_SERIAL: device.get("serial"), + ATTR_TYPE: device.get("type"), + ATTR_LEAP_BUTTON_NUMBER: 1, ATTR_DEVICE_NAME: device["Name"], ATTR_AREA_NAME: device.get("Area", {}).get("Name"), ATTR_ACTION: "press", @@ -214,7 +273,7 @@ async def test_validate_trigger_config_no_device(hass, calls, device_reg): message = { ATTR_SERIAL: "123", ATTR_TYPE: "any", - ATTR_BUTTON_NUMBER: 3, + ATTR_LEAP_BUTTON_NUMBER: 0, ATTR_DEVICE_NAME: "any", ATTR_AREA_NAME: "area", ATTR_ACTION: "press", @@ -259,7 +318,7 @@ async def test_validate_trigger_config_unknown_device(hass, calls, device_reg): message = { ATTR_SERIAL: "123", ATTR_TYPE: "any", - ATTR_BUTTON_NUMBER: 3, + ATTR_LEAP_BUTTON_NUMBER: 0, ATTR_DEVICE_NAME: "any", ATTR_AREA_NAME: "area", ATTR_ACTION: "press",