diff --git a/homeassistant/components/lutron_caseta/__init__.py b/homeassistant/components/lutron_caseta/__init__.py index 9638f769919..5ef195514d3 100644 --- a/homeassistant/components/lutron_caseta/__init__.py +++ b/homeassistant/components/lutron_caseta/__init__.py @@ -79,6 +79,7 @@ PLATFORMS = [ Platform.LIGHT, Platform.SCENE, Platform.SWITCH, + Platform.BUTTON, ] @@ -213,14 +214,14 @@ def _async_register_bridge_device( def _async_register_button_devices( hass: HomeAssistant, config_entry_id: str, - bridge, - bridge_device, + bridge: Smartbridge, + bridge_device: dict[str, Any], button_devices_by_id: dict[int, dict], -) -> tuple[dict[str, dict], dict[int, dict[str, Any]]]: +) -> tuple[dict[str, dict], dict[int, DeviceInfo]]: """Register button devices (Pico Remotes) in the device registry.""" device_registry = dr.async_get(hass) button_devices_by_dr_id: dict[str, dict] = {} - device_info_by_device_id: dict[int, dict[str, Any]] = {} + device_info_by_device_id: dict[int, DeviceInfo] = {} seen: set[str] = set() bridge_devices = bridge.get_devices() @@ -241,10 +242,9 @@ def _async_register_button_devices( seen.add(ha_device_serial) area, name = _area_and_name_from_name(ha_device["name"]) - device_args: dict[str, Any] = { + device_args: DeviceInfo = { "name": f"{area} {name}", "manufacturer": MANUFACTURER, - "config_entry_id": config_entry_id, "identifiers": {(DOMAIN, ha_device_serial)}, "model": f"{ha_device['model']} ({ha_device['type']})", "via_device": (DOMAIN, bridge_device["serial"]), @@ -252,7 +252,9 @@ def _async_register_button_devices( if area != UNASSIGNED_AREA: device_args["suggested_area"] = area - dr_device = device_registry.async_get_or_create(**device_args) + dr_device = device_registry.async_get_or_create( + **device_args, config_entry_id=config_entry_id + ) button_devices_by_dr_id[dr_device.id] = ha_device device_info_by_device_id.setdefault(ha_device["device_id"], device_args) @@ -358,7 +360,7 @@ class LutronCasetaDevice(Entity): _attr_should_poll = False - def __init__(self, device, data): + def __init__(self, device: dict[str, Any], data: LutronCasetaData) -> None: """Set up the base class. [:param]device the device metadata @@ -372,22 +374,19 @@ class LutronCasetaDevice(Entity): if "serial" not in self._device: return - if "parent_device" in device and ( - parent_device_info := data.device_info_by_device_id.get( - device["parent_device"] - ) - ): - # Append the child device name to the end of the parent keypad name to create the entity name - self._attr_name = f'{parent_device_info["name"]} {device["device_name"]}' - # Set the device_info to the same as the Parent Keypad - # The entities will be nested inside the keypad device - self._attr_device_info = parent_device_info + if "parent_device" in device: + # This is a child entity, handle the naming in button.py and switch.py return area, name = _area_and_name_from_name(device["name"]) self._attr_name = full_name = f"{area} {name}" info = DeviceInfo( - identifiers={(DOMAIN, self._handle_none_serial(self.serial))}, + # Historically we used the device serial number for the identifier + # but the serial is usually an integer and a string is expected + # here. Since it would be a breaking change to change the identifier + # we are ignoring the type error here until it can be migrated to + # a string in a future release. + identifiers={(DOMAIN, self._handle_none_serial(self.serial))}, # type: ignore[arg-type] manufacturer=MANUFACTURER, model=f"{device['model']} ({device['type']})", name=full_name, @@ -402,7 +401,7 @@ class LutronCasetaDevice(Entity): """Register callbacks.""" self._smartbridge.add_subscriber(self.device_id, self.async_write_ha_state) - def _handle_none_serial(self, serial: str | None) -> str | int: + def _handle_none_serial(self, serial: str | int | None) -> str | int: """Handle None serial returned by RA3 and QSX processors.""" if serial is None: return f"{self._bridge_unique_id}_{self.device_id}" @@ -414,7 +413,7 @@ class LutronCasetaDevice(Entity): return self._device["device_id"] @property - def serial(self): + def serial(self) -> int | None: """Return the serial number of the device.""" return self._device["serial"] @@ -426,7 +425,12 @@ class LutronCasetaDevice(Entity): @property def extra_state_attributes(self): """Return the state attributes.""" - return {"device_id": self.device_id, "zone_id": self._device["zone"]} + attributes = { + "device_id": self.device_id, + } + if zone := self._device.get("zone"): + attributes["zone_id"] = zone + return attributes class LutronCasetaDeviceUpdatableEntity(LutronCasetaDevice): diff --git a/homeassistant/components/lutron_caseta/button.py b/homeassistant/components/lutron_caseta/button.py new file mode 100644 index 00000000000..713c711f675 --- /dev/null +++ b/homeassistant/components/lutron_caseta/button.py @@ -0,0 +1,98 @@ +"""Support for pico and keypad buttons.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.components.button import ButtonEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import LutronCasetaDevice +from .const import DOMAIN as CASETA_DOMAIN +from .device_trigger import ( + LEAP_TO_DEVICE_TYPE_SUBTYPE_MAP, + _lutron_model_to_device_type, +) +from .models import LutronCasetaData + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Lutron pico and keypad buttons.""" + data: LutronCasetaData = hass.data[CASETA_DOMAIN][config_entry.entry_id] + bridge = data.bridge + button_devices = bridge.get_buttons() + all_devices = data.bridge.get_devices() + device_info_by_device_id = data.device_info_by_device_id + entities: list[LutronCasetaButton] = [] + + for device in button_devices.values(): + + parent_device_info = device_info_by_device_id[device["parent_device"]] + + enabled_default = True + if not (device_name := device.get("device_name")): + # device name (button name) is missing, probably a caseta pico + # try to get the name using the button number from the triggers + # disable the button by default + enabled_default = False + keypad_device = all_devices[device["parent_device"]] + button_numbers = LEAP_TO_DEVICE_TYPE_SUBTYPE_MAP.get( + _lutron_model_to_device_type( + keypad_device["model"], keypad_device["type"] + ), + {}, + ) + device_name = ( + button_numbers.get( + int(device["button_number"]), + f"button {device['button_number']}", + ) + .replace("_", " ") + .title() + ) + + # Append the child device name to the end of the parent keypad name to create the entity name + full_name = f'{parent_device_info.get("name")} {device_name}' + # Set the device_info to the same as the Parent Keypad + # The entities will be nested inside the keypad device + entities.append( + LutronCasetaButton( + device, data, full_name, enabled_default, parent_device_info + ), + ) + + if entities: + async_add_entities(entities) + + +class LutronCasetaButton(LutronCasetaDevice, ButtonEntity): + """Representation of a Lutron pico and keypad button.""" + + def __init__( + self, + device: dict[str, Any], + data: LutronCasetaData, + full_name: str, + enabled_default: bool, + device_info: DeviceInfo, + ) -> None: + """Init a button entity.""" + super().__init__(device, data) + self._attr_entity_registry_enabled_default = enabled_default + self._attr_name = full_name + self._attr_device_info = device_info + + async def async_press(self) -> None: + """Send a button press event.""" + await self._smartbridge.tap_button(self.device_id) + + @property + def serial(self): + """Buttons shouldn't have serial numbers, Return None.""" + return None diff --git a/homeassistant/components/lutron_caseta/models.py b/homeassistant/components/lutron_caseta/models.py index d0e59c25438..e7fb8a2f2b8 100644 --- a/homeassistant/components/lutron_caseta/models.py +++ b/homeassistant/components/lutron_caseta/models.py @@ -6,6 +6,8 @@ from typing import Any from pylutron_caseta.smartbridge import Smartbridge +from homeassistant.helpers.entity import DeviceInfo + @dataclass class LutronCasetaData: @@ -14,4 +16,4 @@ class LutronCasetaData: bridge: Smartbridge bridge_device: dict[str, Any] button_devices: dict[str, dict] - device_info_by_device_id: dict[int, dict[str, Any]] + device_info_by_device_id: dict[int, DeviceInfo] diff --git a/homeassistant/components/lutron_caseta/strings.json b/homeassistant/components/lutron_caseta/strings.json index a89b0c4bbce..0c6ec06005c 100644 --- a/homeassistant/components/lutron_caseta/strings.json +++ b/homeassistant/components/lutron_caseta/strings.json @@ -33,6 +33,9 @@ "button_2": "Second button", "button_3": "Third button", "button_4": "Fourth button", + "button_5": "Fifth button", + "button_6": "Sixth button", + "button_7": "Seventh button", "group_1_button_1": "First Group first button", "group_1_button_2": "First Group second button", "group_2_button_1": "Second Group first button", diff --git a/homeassistant/components/lutron_caseta/switch.py b/homeassistant/components/lutron_caseta/switch.py index d87fd4c3bfa..50c01e6a31f 100644 --- a/homeassistant/components/lutron_caseta/switch.py +++ b/homeassistant/components/lutron_caseta/switch.py @@ -33,6 +33,22 @@ async def async_setup_entry( class LutronCasetaLight(LutronCasetaDeviceUpdatableEntity, SwitchEntity): """Representation of a Lutron Caseta switch.""" + def __init__(self, device, data): + """Init a button entity.""" + + super().__init__(device, data) + self._enabled_default = True + + if "parent_device" not in device: + return + + parent_device_info = data.device_info_by_device_id.get(device["parent_device"]) + # Append the child device name to the end of the parent keypad name to create the entity name + self._attr_name = f'{parent_device_info["name"]} {device["device_name"]}' + # Set the device_info to the same as the Parent Keypad + # The entities will be nested inside the keypad device + self._attr_device_info = parent_device_info + async def async_turn_on(self, **kwargs: Any) -> None: """Turn the switch on.""" await self._smartbridge.turn_on(self.device_id) diff --git a/homeassistant/components/lutron_caseta/translations/en.json b/homeassistant/components/lutron_caseta/translations/en.json index d5245dae2a4..b0ddf459194 100644 --- a/homeassistant/components/lutron_caseta/translations/en.json +++ b/homeassistant/components/lutron_caseta/translations/en.json @@ -33,6 +33,9 @@ "button_2": "Second button", "button_3": "Third button", "button_4": "Fourth button", + "button_5": "Fifth button", + "button_6": "Sixth button", + "button_7": "Seventh button", "close_1": "Close 1", "close_2": "Close 2", "close_3": "Close 3", diff --git a/tests/components/lutron_caseta/__init__.py b/tests/components/lutron_caseta/__init__.py index 91ddfe26fb5..f6af22034a7 100644 --- a/tests/components/lutron_caseta/__init__.py +++ b/tests/components/lutron_caseta/__init__.py @@ -105,11 +105,11 @@ class MockBridge: """Initialize MockBridge instance with configured mock connectivity.""" self.can_connect = can_connect self.is_currently_connected = False - self.buttons = {} self.areas = {} self.occupancy_groups = {} self.scenes = self.get_scenes() self.devices = self.load_devices() + self.buttons = self.load_buttons() async def connect(self): """Connect the mock bridge.""" @@ -119,6 +119,9 @@ class MockBridge: def add_subscriber(self, device_id: str, callback_): """Mock a listener to be notified of state changes.""" + def add_button_subscriber(self, button_id: str, callback_): + """Mock a listener for button presses.""" + def is_connected(self): """Return whether the mock bridge is connected.""" return self.is_currently_connected @@ -187,6 +190,64 @@ class MockBridge: "serial": 5442321, "tilt": None, }, + "9": { + "device_id": "9", + "current_state": -1, + "fan_speed": None, + "tilt": None, + "zone": None, + "name": "Dining Room_Pico", + "button_groups": ["4"], + "occupancy_sensors": None, + "type": "Pico3ButtonRaiseLower", + "model": "PJ2-3BRL-GXX-X01", + "serial": 68551522, + "device_name": "Pico", + "area": "6", + }, + "1355": { + "device_id": "1355", + "current_state": -1, + "fan_speed": None, + "zone": None, + "name": "Hallway_Main Stairs Position 1 Keypad", + "button_groups": ["1363"], + "type": "SunnataKeypad", + "model": "RRST-W3RL-XX", + "serial": 66286451, + "control_station_name": "Main Stairs", + "device_name": "Position 1", + "area": "1205", + }, + } + + def load_buttons(self): + """Load mock buttons into self.buttons.""" + return { + "111": { + "device_id": "111", + "current_state": "Release", + "button_number": 0, + "name": "Dining Room_Pico", + "type": "Pico3ButtonRaiseLower", + "model": "PJ2-3BRL-GXX-X01", + "serial": 68551522, + "parent_device": "9", + }, + "1372": { + "device_id": "1372", + "current_state": "Release", + "button_number": 3, + "button_group": "1363", + "name": "Hallway_Main Stairs Position 1 Keypad", + "type": "SunnataKeypad", + "model": "RRST-W3RL-XX", + "serial": 66286451, + "button_name": "Kitchen Pendants", + "button_led": "1362", + "device_name": "Kitchen Pendants", + "parent_device": "1355", + }, } def get_devices(self) -> dict[str, dict]: @@ -228,6 +289,13 @@ class MockBridge: """Return scenes on the bridge.""" return {} + def get_buttons(self): + """Will return all known buttons connected to the bridge/processor.""" + return self.buttons + + def tap_button(self, button_id: str): + """Mock a button press and release message for the given button ID.""" + async def close(self): """Close the mock bridge connection.""" self.is_currently_connected = False diff --git a/tests/components/lutron_caseta/test_button.py b/tests/components/lutron_caseta/test_button.py new file mode 100644 index 00000000000..767d9a59df4 --- /dev/null +++ b/tests/components/lutron_caseta/test_button.py @@ -0,0 +1,50 @@ +"""Tests for the Lutron Caseta integration.""" + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import MockBridge, async_setup_integration + + +async def test_button_unique_id(hass: HomeAssistant) -> None: + """Test a button unique id.""" + await async_setup_integration(hass, MockBridge) + + ra3_button_entity_id = ( + "button.hallway_main_stairs_position_1_keypad_kitchen_pendants" + ) + caseta_button_entity_id = "button.dining_room_pico_on" + + entity_registry = er.async_get(hass) + + # Assert that Caseta buttons will have the bridge serial hash and the zone id as the uniqueID + assert entity_registry.async_get(ra3_button_entity_id).unique_id == "000004d2_1372" + assert ( + entity_registry.async_get(caseta_button_entity_id).unique_id == "000004d2_111" + ) + + +async def test_button_press(hass: HomeAssistant) -> None: + """Test a button press.""" + await async_setup_integration(hass, MockBridge) + + ra3_button_entity_id = ( + "button.hallway_main_stairs_position_1_keypad_kitchen_pendants" + ) + + state = hass.states.get(ra3_button_entity_id) + assert state + assert state.state == STATE_UNKNOWN + + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: ra3_button_entity_id}, + blocking=False, + ) + await hass.async_block_till_done() + + state = hass.states.get(ra3_button_entity_id) + assert state diff --git a/tests/components/lutron_caseta/test_diagnostics.py b/tests/components/lutron_caseta/test_diagnostics.py index 42fc1dac5c1..b0d6aae1058 100644 --- a/tests/components/lutron_caseta/test_diagnostics.py +++ b/tests/components/lutron_caseta/test_diagnostics.py @@ -41,7 +41,32 @@ async def test_diagnostics(hass, hass_client) -> None: assert diag == { "data": { "areas": {}, - "buttons": {}, + "buttons": { + "111": { + "device_id": "111", + "current_state": "Release", + "button_number": 0, + "name": "Dining Room_Pico", + "type": "Pico3ButtonRaiseLower", + "model": "PJ2-3BRL-GXX-X01", + "serial": 68551522, + "parent_device": "9", + }, + "1372": { + "device_id": "1372", + "current_state": "Release", + "button_number": 3, + "button_group": "1363", + "name": "Hallway_Main Stairs Position 1 Keypad", + "type": "SunnataKeypad", + "model": "RRST-W3RL-XX", + "serial": 66286451, + "button_name": "Kitchen Pendants", + "button_led": "1362", + "device_name": "Kitchen Pendants", + "parent_device": "1355", + }, + }, "devices": { "1": { "model": "model", @@ -109,6 +134,35 @@ async def test_diagnostics(hass, hass_client) -> None: "serial": 5442321, "tilt": None, }, + "9": { + "device_id": "9", + "current_state": -1, + "fan_speed": None, + "tilt": None, + "zone": None, + "name": "Dining Room_Pico", + "button_groups": ["4"], + "occupancy_sensors": None, + "type": "Pico3ButtonRaiseLower", + "model": "PJ2-3BRL-GXX-X01", + "serial": 68551522, + "device_name": "Pico", + "area": "6", + }, + "1355": { + "device_id": "1355", + "current_state": -1, + "fan_speed": None, + "zone": None, + "name": "Hallway_Main Stairs Position 1 Keypad", + "button_groups": ["1363"], + "type": "SunnataKeypad", + "model": "RRST-W3RL-XX", + "serial": 66286451, + "control_station_name": "Main Stairs", + "device_name": "Position 1", + "area": "1205", + }, }, "occupancy_groups": {}, "scenes": {},