diff --git a/CODEOWNERS b/CODEOWNERS index 9a0c092eceb..2513e290230 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -630,8 +630,8 @@ build.json @home-assistant/supervisor /tests/components/luftdaten/ @fabaff @frenck /homeassistant/components/lupusec/ @majuss /homeassistant/components/lutron/ @JonGilmore -/homeassistant/components/lutron_caseta/ @swails @bdraco -/tests/components/lutron_caseta/ @swails @bdraco +/homeassistant/components/lutron_caseta/ @swails @bdraco @danaues +/tests/components/lutron_caseta/ @swails @bdraco @danaues /homeassistant/components/lyric/ @timmo001 /tests/components/lyric/ @timmo001 /homeassistant/components/mastodon/ @fabaff diff --git a/homeassistant/components/lutron_caseta/__init__.py b/homeassistant/components/lutron_caseta/__init__.py index 5653504c98a..bcbaedeb8d1 100644 --- a/homeassistant/components/lutron_caseta/__init__.py +++ b/homeassistant/components/lutron_caseta/__init__.py @@ -387,6 +387,13 @@ class LutronCasetaDeviceUpdatableEntity(LutronCasetaDevice): self._device = self._smartbridge.get_device_by_id(self.device_id) _LOGGER.debug(self._device) + @property + def unique_id(self): + """Return a unique identifier if serial number is None.""" + if self.serial is None: + return f"{self._bridge_unique_id}_{self.device_id}" + return super().unique_id + def _id_to_identifier(lutron_id: str) -> tuple[str, str]: """Convert a lutron caseta identifier to a device identifier.""" diff --git a/homeassistant/components/lutron_caseta/manifest.json b/homeassistant/components/lutron_caseta/manifest.json index 206d8b51233..c80d0deb794 100644 --- a/homeassistant/components/lutron_caseta/manifest.json +++ b/homeassistant/components/lutron_caseta/manifest.json @@ -8,7 +8,7 @@ "homekit": { "models": ["Smart Bridge"] }, - "codeowners": ["@swails", "@bdraco"], + "codeowners": ["@swails", "@bdraco", "@danaues"], "iot_class": "local_push", "loggers": ["pylutron_caseta"] } diff --git a/tests/components/lutron_caseta/__init__.py b/tests/components/lutron_caseta/__init__.py index ace4066ae3b..91ddfe26fb5 100644 --- a/tests/components/lutron_caseta/__init__.py +++ b/tests/components/lutron_caseta/__init__.py @@ -1,6 +1,103 @@ """Tests for the Lutron Caseta integration.""" +from unittest.mock import patch + +from homeassistant.components.lutron_caseta import DOMAIN +from homeassistant.components.lutron_caseta.const import ( + CONF_CA_CERTS, + CONF_CERTFILE, + CONF_KEYFILE, +) +from homeassistant.const import CONF_HOST + +from tests.common import MockConfigEntry + +ENTRY_MOCK_DATA = { + CONF_HOST: "1.1.1.1", + CONF_KEYFILE: "", + CONF_CERTFILE: "", + CONF_CA_CERTS: "", +} + +_LEAP_DEVICE_TYPES = { + "light": [ + "WallDimmer", + "PlugInDimmer", + "InLineDimmer", + "SunnataDimmer", + "TempInWallPaddleDimmer", + "WallDimmerWithPreset", + "Dimmed", + ], + "switch": [ + "WallSwitch", + "OutdoorPlugInSwitch", + "PlugInSwitch", + "InLineSwitch", + "PowPakSwitch", + "SunnataSwitch", + "TempInWallPaddleSwitch", + "Switched", + ], + "fan": [ + "CasetaFanSpeedController", + "MaestroFanSpeedController", + "FanSpeed", + ], + "cover": [ + "SerenaHoneycombShade", + "SerenaRollerShade", + "TriathlonHoneycombShade", + "TriathlonRollerShade", + "QsWirelessShade", + "QsWirelessHorizontalSheerBlind", + "QsWirelessWoodBlind", + "RightDrawDrape", + "Shade", + "SerenaTiltOnlyWoodBlind", + ], + "sensor": [ + "Pico1Button", + "Pico2Button", + "Pico2ButtonRaiseLower", + "Pico3Button", + "Pico3ButtonRaiseLower", + "Pico4Button", + "Pico4ButtonScene", + "Pico4ButtonZone", + "Pico4Button2Group", + "FourGroupRemote", + "SeeTouchTabletopKeypad", + "SunnataKeypad", + "SunnataKeypad_2Button", + "SunnataKeypad_3ButtonRaiseLower", + "SunnataKeypad_4Button", + "SeeTouchHybridKeypad", + "SeeTouchInternational", + "SeeTouchKeypad", + "HomeownerKeypad", + "GrafikTHybridKeypad", + "AlisseKeypad", + "PalladiomKeypad", + ], +} + + +async def async_setup_integration(hass, mock_bridge) -> MockConfigEntry: + """Set up a mock bridge.""" + mock_entry = MockConfigEntry(domain=DOMAIN, data=ENTRY_MOCK_DATA) + mock_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.lutron_caseta.Smartbridge.create_tls" + ) as create_tls: + create_tls.return_value = mock_bridge(can_connect=True) + await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() + return mock_entry + + class MockBridge: """Mock Lutron bridge that emulates configured connected status.""" @@ -12,26 +109,120 @@ class MockBridge: self.areas = {} self.occupancy_groups = {} self.scenes = self.get_scenes() - self.devices = self.get_devices() + self.devices = self.load_devices() async def connect(self): """Connect the mock bridge.""" if self.can_connect: self.is_currently_connected = True + def add_subscriber(self, device_id: str, callback_): + """Mock a listener to be notified of state changes.""" + def is_connected(self): """Return whether the mock bridge is connected.""" return self.is_currently_connected - def get_devices(self): - """Return devices on the bridge.""" + def load_devices(self): + """Load mock devices into self.devices.""" return { - "1": {"serial": 1234, "name": "bridge", "model": "model", "type": "type"} + "1": {"serial": 1234, "name": "bridge", "model": "model", "type": "type"}, + "801": { + "device_id": "801", + "current_state": 100, + "fan_speed": None, + "zone": "801", + "name": "Basement Bedroom_Main Lights", + "button_groups": None, + "type": "Dimmed", + "model": None, + "serial": None, + "tilt": None, + }, + "802": { + "device_id": "802", + "current_state": 100, + "fan_speed": None, + "zone": "802", + "name": "Basement Bedroom_Left Shade", + "button_groups": None, + "type": "SerenaRollerShade", + "model": None, + "serial": None, + "tilt": None, + }, + "803": { + "device_id": "803", + "current_state": 100, + "fan_speed": None, + "zone": "803", + "name": "Basement Bathroom_Exhaust Fan", + "button_groups": None, + "type": "Switched", + "model": None, + "serial": None, + "tilt": None, + }, + "804": { + "device_id": "804", + "current_state": 100, + "fan_speed": None, + "zone": "804", + "name": "Master Bedroom_Ceiling Fan", + "button_groups": None, + "type": "FanSpeed", + "model": None, + "serial": None, + "tilt": None, + }, + "901": { + "device_id": "901", + "current_state": 100, + "fan_speed": None, + "zone": "901", + "name": "Kitchen_Main Lights", + "button_groups": None, + "type": "WallDimmer", + "model": None, + "serial": 5442321, + "tilt": None, + }, } - def get_devices_by_domain(self, domain): - """Return devices on the bridge.""" - return {} + def get_devices(self) -> dict[str, dict]: + """Will return all known devices connected to the Smart Bridge.""" + return self.devices + + def get_devices_by_domain(self, domain: str) -> list[dict]: + """ + Return a list of devices for the given domain. + + :param domain: one of 'light', 'switch', 'cover', 'fan' or 'sensor' + :returns list of zero or more of the devices + """ + types = _LEAP_DEVICE_TYPES.get(domain, None) + + # return immediately if not a supported domain + if types is None: + return [] + + return self.get_devices_by_types(types) + + def get_devices_by_type(self, type_: str) -> list[dict]: + """ + Will return all devices of a given device type. + + :param type_: LEAP device type, e.g. WallSwitch + """ + return [device for device in self.devices.values() if device["type"] == type_] + + def get_devices_by_types(self, types: list[str]) -> list[dict]: + """ + Will return all devices for a list of given device types. + + :param types: list of LEAP device types such as WallSwitch, WallDimmer + """ + return [device for device in self.devices.values() if device["type"] in types] def get_scenes(self): """Return scenes on the bridge.""" diff --git a/tests/components/lutron_caseta/test_config_flow.py b/tests/components/lutron_caseta/test_config_flow.py index b5e8271d351..d1997051e26 100644 --- a/tests/components/lutron_caseta/test_config_flow.py +++ b/tests/components/lutron_caseta/test_config_flow.py @@ -20,7 +20,7 @@ from homeassistant.components.lutron_caseta.const import ( ) from homeassistant.const import CONF_HOST -from . import MockBridge +from . import ENTRY_MOCK_DATA, MockBridge from tests.common import MockConfigEntry @@ -151,13 +151,7 @@ async def test_bridge_invalid_ssl_error(hass): async def test_duplicate_bridge_import(hass): """Test that creating a bridge entry with a duplicate host errors.""" - entry_mock_data = { - CONF_HOST: "1.1.1.1", - CONF_KEYFILE: "", - CONF_CERTFILE: "", - CONF_CA_CERTS: "", - } - mock_entry = MockConfigEntry(domain=DOMAIN, data=entry_mock_data) + mock_entry = MockConfigEntry(domain=DOMAIN, data=ENTRY_MOCK_DATA) mock_entry.add_to_hass(hass) with patch( @@ -168,7 +162,7 @@ async def test_duplicate_bridge_import(hass): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, - data=entry_mock_data, + data=ENTRY_MOCK_DATA, ) assert result["type"] == data_entry_flow.FlowResultType.ABORT diff --git a/tests/components/lutron_caseta/test_cover.py b/tests/components/lutron_caseta/test_cover.py new file mode 100644 index 00000000000..ef5fc2a5228 --- /dev/null +++ b/tests/components/lutron_caseta/test_cover.py @@ -0,0 +1,19 @@ +"""Tests for the Lutron Caseta integration.""" + + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import MockBridge, async_setup_integration + + +async def test_cover_unique_id(hass: HomeAssistant) -> None: + """Test a light unique id.""" + await async_setup_integration(hass, MockBridge) + + cover_entity_id = "cover.basement_bedroom_left_shade" + + entity_registry = er.async_get(hass) + + # Assert that Caseta covers will have the bridge serial hash and the zone id as the uniqueID + assert entity_registry.async_get(cover_entity_id).unique_id == "000004d2_802" diff --git a/tests/components/lutron_caseta/test_diagnostics.py b/tests/components/lutron_caseta/test_diagnostics.py index 89fcb65df9d..42fc1dac5c1 100644 --- a/tests/components/lutron_caseta/test_diagnostics.py +++ b/tests/components/lutron_caseta/test_diagnostics.py @@ -48,7 +48,67 @@ async def test_diagnostics(hass, hass_client) -> None: "name": "bridge", "serial": 1234, "type": "type", - } + }, + "801": { + "device_id": "801", + "current_state": 100, + "fan_speed": None, + "zone": "801", + "name": "Basement Bedroom_Main Lights", + "button_groups": None, + "type": "Dimmed", + "model": None, + "serial": None, + "tilt": None, + }, + "802": { + "device_id": "802", + "current_state": 100, + "fan_speed": None, + "zone": "802", + "name": "Basement Bedroom_Left Shade", + "button_groups": None, + "type": "SerenaRollerShade", + "model": None, + "serial": None, + "tilt": None, + }, + "803": { + "device_id": "803", + "current_state": 100, + "fan_speed": None, + "zone": "803", + "name": "Basement Bathroom_Exhaust Fan", + "button_groups": None, + "type": "Switched", + "model": None, + "serial": None, + "tilt": None, + }, + "804": { + "device_id": "804", + "current_state": 100, + "fan_speed": None, + "zone": "804", + "name": "Master Bedroom_Ceiling Fan", + "button_groups": None, + "type": "FanSpeed", + "model": None, + "serial": None, + "tilt": None, + }, + "901": { + "device_id": "901", + "current_state": 100, + "fan_speed": None, + "zone": "901", + "name": "Kitchen_Main Lights", + "button_groups": None, + "type": "WallDimmer", + "model": None, + "serial": 5442321, + "tilt": None, + }, }, "occupancy_groups": {}, "scenes": {}, diff --git a/tests/components/lutron_caseta/test_fan.py b/tests/components/lutron_caseta/test_fan.py new file mode 100644 index 00000000000..f9c86cc9c58 --- /dev/null +++ b/tests/components/lutron_caseta/test_fan.py @@ -0,0 +1,19 @@ +"""Tests for the Lutron Caseta integration.""" + + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import MockBridge, async_setup_integration + + +async def test_fan_unique_id(hass: HomeAssistant) -> None: + """Test a light unique id.""" + await async_setup_integration(hass, MockBridge) + + fan_entity_id = "fan.master_bedroom_ceiling_fan" + + entity_registry = er.async_get(hass) + + # Assert that Caseta covers will have the bridge serial hash and the zone id as the uniqueID + assert entity_registry.async_get(fan_entity_id).unique_id == "000004d2_804" diff --git a/tests/components/lutron_caseta/test_light.py b/tests/components/lutron_caseta/test_light.py new file mode 100644 index 00000000000..6449ce04832 --- /dev/null +++ b/tests/components/lutron_caseta/test_light.py @@ -0,0 +1,27 @@ +"""Tests for the Lutron Caseta integration.""" + + +from homeassistant.const import STATE_ON +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import MockBridge, async_setup_integration + + +async def test_light_unique_id(hass: HomeAssistant) -> None: + """Test a light unique id.""" + await async_setup_integration(hass, MockBridge) + + ra3_entity_id = "light.basement_bedroom_main_lights" + caseta_entity_id = "light.kitchen_main_lights" + + entity_registry = er.async_get(hass) + + # Assert that RA3 lights will have the bridge serial hash and the zone id as the uniqueID + assert entity_registry.async_get(ra3_entity_id).unique_id == "000004d2_801" + + # Assert that Caseta lights will have the serial number as the uniqueID + assert entity_registry.async_get(caseta_entity_id).unique_id == "5442321" + + state = hass.states.get(ra3_entity_id) + assert state.state == STATE_ON diff --git a/tests/components/lutron_caseta/test_switch.py b/tests/components/lutron_caseta/test_switch.py new file mode 100644 index 00000000000..842aca94423 --- /dev/null +++ b/tests/components/lutron_caseta/test_switch.py @@ -0,0 +1,18 @@ +"""Tests for the Lutron Caseta integration.""" + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import MockBridge, async_setup_integration + + +async def test_switch_unique_id(hass: HomeAssistant) -> None: + """Test a light unique id.""" + await async_setup_integration(hass, MockBridge) + + switch_entity_id = "switch.basement_bathroom_exhaust_fan" + + entity_registry = er.async_get(hass) + + # Assert that Caseta covers will have the bridge serial hash and the zone id as the uniqueID + assert entity_registry.async_get(switch_entity_id).unique_id == "000004d2_803"