Add button entities for Lutron Caseta/RA3/HWQSX (#79963)

Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
Kevin Addeman 2022-10-12 16:29:28 -04:00 committed by GitHub
parent a396e35c21
commit 82322e3804
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 323 additions and 25 deletions

View File

@ -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):

View 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

View File

@ -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]

View File

@ -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",

View File

@ -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)

View File

@ -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",

View File

@ -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

View 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

View File

@ -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": {},