mirror of
https://github.com/home-assistant/core.git
synced 2025-07-21 12:17:07 +00:00
Add button entities for Lutron Caseta/RA3/HWQSX (#79963)
Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
parent
a396e35c21
commit
82322e3804
@ -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):
|
||||
|
98
homeassistant/components/lutron_caseta/button.py
Normal file
98
homeassistant/components/lutron_caseta/button.py
Normal file
@ -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
|
@ -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]
|
||||
|
@ -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",
|
||||
|
@ -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)
|
||||
|
@ -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",
|
||||
|
@ -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
|
||||
|
50
tests/components/lutron_caseta/test_button.py
Normal file
50
tests/components/lutron_caseta/test_button.py
Normal file
@ -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
|
@ -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": {},
|
||||
|
Loading…
x
Reference in New Issue
Block a user