diff --git a/homeassistant/components/rainbird/binary_sensor.py b/homeassistant/components/rainbird/binary_sensor.py index b5886011ea3..3333d8bc4cb 100644 --- a/homeassistant/components/rainbird/binary_sensor.py +++ b/homeassistant/components/rainbird/binary_sensor.py @@ -48,8 +48,11 @@ class RainBirdSensor(CoordinatorEntity[RainbirdUpdateCoordinator], BinarySensorE """Initialize the Rain Bird sensor.""" super().__init__(coordinator) self.entity_description = description - self._attr_unique_id = f"{coordinator.serial_number}-{description.key}" - self._attr_device_info = coordinator.device_info + if coordinator.unique_id: + self._attr_unique_id = f"{coordinator.unique_id}-{description.key}" + self._attr_device_info = coordinator.device_info + else: + self._attr_name = f"{coordinator.device_name} Rainsensor" @property def is_on(self) -> bool | None: diff --git a/homeassistant/components/rainbird/calendar.py b/homeassistant/components/rainbird/calendar.py index 4d8cc38c8bf..356f7d7cc4e 100644 --- a/homeassistant/components/rainbird/calendar.py +++ b/homeassistant/components/rainbird/calendar.py @@ -34,8 +34,9 @@ async def async_setup_entry( [ RainBirdCalendarEntity( data.schedule_coordinator, - data.coordinator.serial_number, + data.coordinator.unique_id, data.coordinator.device_info, + data.coordinator.device_name, ) ] ) @@ -47,20 +48,24 @@ class RainBirdCalendarEntity( """A calendar event entity.""" _attr_has_entity_name = True - _attr_name = None + _attr_name: str | None = None _attr_icon = "mdi:sprinkler" def __init__( self, coordinator: RainbirdScheduleUpdateCoordinator, - serial_number: str, - device_info: DeviceInfo, + unique_id: str | None, + device_info: DeviceInfo | None, + device_name: str, ) -> None: """Create the Calendar event device.""" super().__init__(coordinator) self._event: CalendarEvent | None = None - self._attr_unique_id = serial_number - self._attr_device_info = device_info + if unique_id: + self._attr_unique_id = unique_id + self._attr_device_info = device_info + else: + self._attr_name = device_name @property def event(self) -> CalendarEvent | None: diff --git a/homeassistant/components/rainbird/coordinator.py b/homeassistant/components/rainbird/coordinator.py index 5c40ef808b2..763e50fe5d9 100644 --- a/homeassistant/components/rainbird/coordinator.py +++ b/homeassistant/components/rainbird/coordinator.py @@ -21,7 +21,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import CONF_SERIAL_NUMBER, DOMAIN, MANUFACTURER, TIMEOUT_SECONDS +from .const import DOMAIN, MANUFACTURER, TIMEOUT_SECONDS UPDATE_INTERVAL = datetime.timedelta(minutes=1) # The calendar data requires RPCs for each program/zone, and the data rarely @@ -51,7 +51,7 @@ class RainbirdUpdateCoordinator(DataUpdateCoordinator[RainbirdDeviceState]): hass: HomeAssistant, name: str, controller: AsyncRainbirdController, - serial_number: str, + unique_id: str | None, model_info: ModelAndVersion, ) -> None: """Initialize RainbirdUpdateCoordinator.""" @@ -62,7 +62,7 @@ class RainbirdUpdateCoordinator(DataUpdateCoordinator[RainbirdDeviceState]): update_interval=UPDATE_INTERVAL, ) self._controller = controller - self._serial_number = serial_number + self._unique_id = unique_id self._zones: set[int] | None = None self._model_info = model_info @@ -72,16 +72,23 @@ class RainbirdUpdateCoordinator(DataUpdateCoordinator[RainbirdDeviceState]): return self._controller @property - def serial_number(self) -> str: - """Return the device serial number.""" - return self._serial_number + def unique_id(self) -> str | None: + """Return the config entry unique id.""" + return self._unique_id @property - def device_info(self) -> DeviceInfo: + def device_name(self) -> str: + """Device name for the rainbird controller.""" + return f"{MANUFACTURER} Controller" + + @property + def device_info(self) -> DeviceInfo | None: """Return information about the device.""" + if not self._unique_id: + return None return DeviceInfo( - name=f"{MANUFACTURER} Controller", - identifiers={(DOMAIN, self._serial_number)}, + name=self.device_name, + identifiers={(DOMAIN, self._unique_id)}, manufacturer=MANUFACTURER, model=self._model_info.model_name, sw_version=f"{self._model_info.major}.{self._model_info.minor}", @@ -164,7 +171,7 @@ class RainbirdData: self.hass, name=self.entry.title, controller=self.controller, - serial_number=self.entry.data[CONF_SERIAL_NUMBER], + unique_id=self.entry.unique_id, model_info=self.model_info, ) diff --git a/homeassistant/components/rainbird/number.py b/homeassistant/components/rainbird/number.py index d0945609a1b..1e72fabafcd 100644 --- a/homeassistant/components/rainbird/number.py +++ b/homeassistant/components/rainbird/number.py @@ -51,8 +51,11 @@ class RainDelayNumber(CoordinatorEntity[RainbirdUpdateCoordinator], NumberEntity ) -> None: """Initialize the Rain Bird sensor.""" super().__init__(coordinator) - self._attr_unique_id = f"{coordinator.serial_number}-rain-delay" - self._attr_device_info = coordinator.device_info + if coordinator.unique_id: + self._attr_unique_id = f"{coordinator.unique_id}-rain-delay" + self._attr_device_info = coordinator.device_info + else: + self._attr_name = f"{coordinator.device_name} Rain delay" @property def native_value(self) -> float | None: diff --git a/homeassistant/components/rainbird/sensor.py b/homeassistant/components/rainbird/sensor.py index 32eb053f478..d44e7156cb5 100644 --- a/homeassistant/components/rainbird/sensor.py +++ b/homeassistant/components/rainbird/sensor.py @@ -52,8 +52,13 @@ class RainBirdSensor(CoordinatorEntity[RainbirdUpdateCoordinator], SensorEntity) """Initialize the Rain Bird sensor.""" super().__init__(coordinator) self.entity_description = description - self._attr_unique_id = f"{coordinator.serial_number}-{description.key}" - self._attr_device_info = coordinator.device_info + if coordinator.unique_id: + self._attr_unique_id = f"{coordinator.unique_id}-{description.key}" + self._attr_device_info = coordinator.device_info + else: + self._attr_name = ( + f"{coordinator.device_name} {description.key.capitalize()}" + ) @property def native_value(self) -> StateType: diff --git a/homeassistant/components/rainbird/switch.py b/homeassistant/components/rainbird/switch.py index cafc541d860..62b3b0e9a8c 100644 --- a/homeassistant/components/rainbird/switch.py +++ b/homeassistant/components/rainbird/switch.py @@ -65,20 +65,23 @@ class RainBirdSwitch(CoordinatorEntity[RainbirdUpdateCoordinator], SwitchEntity) """Initialize a Rain Bird Switch Device.""" super().__init__(coordinator) self._zone = zone + if coordinator.unique_id: + self._attr_unique_id = f"{coordinator.unique_id}-{zone}" + device_name = f"{MANUFACTURER} Sprinkler {zone}" if imported_name: self._attr_name = imported_name self._attr_has_entity_name = False else: - self._attr_name = None + self._attr_name = None if coordinator.unique_id else device_name self._attr_has_entity_name = True self._duration_minutes = duration_minutes - self._attr_unique_id = f"{coordinator.serial_number}-{zone}" - self._attr_device_info = DeviceInfo( - name=f"{MANUFACTURER} Sprinkler {zone}", - identifiers={(DOMAIN, self._attr_unique_id)}, - manufacturer=MANUFACTURER, - via_device=(DOMAIN, coordinator.serial_number), - ) + if coordinator.unique_id and self._attr_unique_id: + self._attr_device_info = DeviceInfo( + name=device_name, + identifiers={(DOMAIN, self._attr_unique_id)}, + manufacturer=MANUFACTURER, + via_device=(DOMAIN, coordinator.unique_id), + ) @property def extra_state_attributes(self): diff --git a/tests/components/rainbird/conftest.py b/tests/components/rainbird/conftest.py index dbc3456117c..f25bdfb1d86 100644 --- a/tests/components/rainbird/conftest.py +++ b/tests/components/rainbird/conftest.py @@ -86,7 +86,7 @@ def yaml_config() -> dict[str, Any]: @pytest.fixture -async def unique_id() -> str: +async def config_entry_unique_id() -> str: """Fixture for serial number used in the config entry.""" return SERIAL_NUMBER @@ -100,13 +100,13 @@ async def config_entry_data() -> dict[str, Any]: @pytest.fixture async def config_entry( config_entry_data: dict[str, Any] | None, - unique_id: str, + config_entry_unique_id: str | None, ) -> MockConfigEntry | None: """Fixture for MockConfigEntry.""" if config_entry_data is None: return None return MockConfigEntry( - unique_id=unique_id, + unique_id=config_entry_unique_id, domain=DOMAIN, data=config_entry_data, options={ATTR_DURATION: DEFAULT_TRIGGER_TIME_MINUTES}, diff --git a/tests/components/rainbird/test_binary_sensor.py b/tests/components/rainbird/test_binary_sensor.py index cfa2c4d2684..e372a10ae23 100644 --- a/tests/components/rainbird/test_binary_sensor.py +++ b/tests/components/rainbird/test_binary_sensor.py @@ -5,6 +5,7 @@ import pytest from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from .conftest import RAIN_SENSOR_OFF, RAIN_SENSOR_ON, ComponentSetup @@ -25,6 +26,7 @@ async def test_rainsensor( hass: HomeAssistant, setup_integration: ComponentSetup, responses: list[AiohttpClientMockResponse], + entity_registry: er.EntityRegistry, expected_state: bool, ) -> None: """Test rainsensor binary sensor.""" @@ -38,3 +40,37 @@ async def test_rainsensor( "friendly_name": "Rain Bird Controller Rainsensor", "icon": "mdi:water", } + + entity_entry = entity_registry.async_get( + "binary_sensor.rain_bird_controller_rainsensor" + ) + assert entity_entry + assert entity_entry.unique_id == "1263613994342-rainsensor" + + +@pytest.mark.parametrize( + ("config_entry_unique_id"), + [ + (None), + ], +) +async def test_no_unique_id( + hass: HomeAssistant, + setup_integration: ComponentSetup, + responses: list[AiohttpClientMockResponse], + entity_registry: er.EntityRegistry, +) -> None: + """Test rainsensor binary sensor with no unique id.""" + + assert await setup_integration() + + rainsensor = hass.states.get("binary_sensor.rain_bird_controller_rainsensor") + assert rainsensor is not None + assert ( + rainsensor.attributes.get("friendly_name") == "Rain Bird Controller Rainsensor" + ) + + entity_entry = entity_registry.async_get( + "binary_sensor.rain_bird_controller_rainsensor" + ) + assert not entity_entry diff --git a/tests/components/rainbird/test_calendar.py b/tests/components/rainbird/test_calendar.py index 2028fccc24f..2e486226a7b 100644 --- a/tests/components/rainbird/test_calendar.py +++ b/tests/components/rainbird/test_calendar.py @@ -14,6 +14,7 @@ import pytest from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from .conftest import ComponentSetup, mock_response, mock_response_error @@ -176,6 +177,7 @@ async def test_event_state( freezer: FrozenDateTimeFactory, freeze_time: datetime.datetime, expected_state: str, + entity_registry: er.EntityRegistry, ) -> None: """Test calendar upcoming event state.""" freezer.move_to(freeze_time) @@ -196,6 +198,10 @@ async def test_event_state( } assert state.state == expected_state + entity = entity_registry.async_get(TEST_ENTITY) + assert entity + assert entity.unique_id == 1263613994342 + @pytest.mark.parametrize( ("model_and_version_response", "has_entity"), @@ -270,3 +276,27 @@ async def test_program_schedule_disabled( "friendly_name": "Rain Bird Controller", "icon": "mdi:sprinkler", } + + +@pytest.mark.parametrize( + ("config_entry_unique_id"), + [ + (None), + ], +) +async def test_no_unique_id( + hass: HomeAssistant, + setup_integration: ComponentSetup, + get_events: GetEventsFn, + entity_registry: er.EntityRegistry, +) -> None: + """Test calendar entity with no unique id.""" + + assert await setup_integration() + + state = hass.states.get(TEST_ENTITY) + assert state is not None + assert state.attributes.get("friendly_name") == "Rain Bird Controller" + + entity_entry = entity_registry.async_get(TEST_ENTITY) + assert not entity_entry diff --git a/tests/components/rainbird/test_config_flow.py b/tests/components/rainbird/test_config_flow.py index e7337ad6508..cfc4ff3b5cb 100644 --- a/tests/components/rainbird/test_config_flow.py +++ b/tests/components/rainbird/test_config_flow.py @@ -106,7 +106,7 @@ async def test_controller_flow( @pytest.mark.parametrize( ( - "unique_id", + "config_entry_unique_id", "config_entry_data", "config_flow_responses", "expected_config_entry", @@ -154,7 +154,7 @@ async def test_multiple_config_entries( @pytest.mark.parametrize( ( - "unique_id", + "config_entry_unique_id", "config_entry_data", "config_flow_responses", ), diff --git a/tests/components/rainbird/test_number.py b/tests/components/rainbird/test_number.py index 6ce7d10c9f2..5d208f08a25 100644 --- a/tests/components/rainbird/test_number.py +++ b/tests/components/rainbird/test_number.py @@ -10,7 +10,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import device_registry as dr, entity_registry as er from .conftest import ( ACK_ECHO, @@ -39,8 +39,9 @@ async def test_number_values( hass: HomeAssistant, setup_integration: ComponentSetup, expected_state: str, + entity_registry: er.EntityRegistry, ) -> None: - """Test sensor platform.""" + """Test number platform.""" assert await setup_integration() @@ -57,6 +58,10 @@ async def test_number_values( "unit_of_measurement": "d", } + entity_entry = entity_registry.async_get("number.rain_bird_controller_rain_delay") + assert entity_entry + assert entity_entry.unique_id == "1263613994342-rain-delay" + async def test_set_value( hass: HomeAssistant, @@ -127,3 +132,28 @@ async def test_set_value_error( ) assert len(aioclient_mock.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("config_entry_unique_id"), + [ + (None), + ], +) +async def test_no_unique_id( + hass: HomeAssistant, + setup_integration: ComponentSetup, + entity_registry: er.EntityRegistry, +) -> None: + """Test number platform with no unique id.""" + + assert await setup_integration() + + raindelay = hass.states.get("number.rain_bird_controller_rain_delay") + assert raindelay is not None + assert ( + raindelay.attributes.get("friendly_name") == "Rain Bird Controller Rain delay" + ) + + entity_entry = entity_registry.async_get("number.rain_bird_controller_rain_delay") + assert not entity_entry diff --git a/tests/components/rainbird/test_sensor.py b/tests/components/rainbird/test_sensor.py index 049a5f15c45..d8fb053c0ff 100644 --- a/tests/components/rainbird/test_sensor.py +++ b/tests/components/rainbird/test_sensor.py @@ -5,8 +5,9 @@ import pytest from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er -from .conftest import RAIN_DELAY, RAIN_DELAY_OFF, ComponentSetup +from .conftest import CONFIG_ENTRY_DATA, RAIN_DELAY, RAIN_DELAY_OFF, ComponentSetup @pytest.fixture @@ -22,6 +23,7 @@ def platforms() -> list[str]: async def test_sensors( hass: HomeAssistant, setup_integration: ComponentSetup, + entity_registry: er.EntityRegistry, expected_state: str, ) -> None: """Test sensor platform.""" @@ -35,3 +37,46 @@ async def test_sensors( "friendly_name": "Rain Bird Controller Raindelay", "icon": "mdi:water-off", } + + entity_entry = entity_registry.async_get("sensor.rain_bird_controller_raindelay") + assert entity_entry + assert entity_entry.unique_id == "1263613994342-raindelay" + + +@pytest.mark.parametrize( + ("config_entry_unique_id", "config_entry_data"), + [ + # Config entry setup without a unique id since it had no serial number + ( + None, + { + **CONFIG_ENTRY_DATA, + "serial_number": 0, + }, + ), + # Legacy case for old config entries with serial number 0 preserves old behavior + ( + "0", + { + **CONFIG_ENTRY_DATA, + "serial_number": 0, + }, + ), + ], +) +async def test_sensor_no_unique_id( + hass: HomeAssistant, + setup_integration: ComponentSetup, + entity_registry: er.EntityRegistry, + config_entry_unique_id: str | None, +) -> None: + """Test sensor platform with no unique id.""" + + assert await setup_integration() + + raindelay = hass.states.get("sensor.rain_bird_controller_raindelay") + assert raindelay is not None + assert raindelay.attributes.get("friendly_name") == "Rain Bird Controller Raindelay" + + entity_entry = entity_registry.async_get("sensor.rain_bird_controller_raindelay") + assert (entity_entry is None) == (config_entry_unique_id is None) diff --git a/tests/components/rainbird/test_switch.py b/tests/components/rainbird/test_switch.py index 9ce5e799c92..46a875e8928 100644 --- a/tests/components/rainbird/test_switch.py +++ b/tests/components/rainbird/test_switch.py @@ -8,6 +8,7 @@ from homeassistant.components.rainbird import DOMAIN from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er from .conftest import ( ACK_ECHO, @@ -57,6 +58,7 @@ async def test_no_zones( async def test_zones( hass: HomeAssistant, setup_integration: ComponentSetup, + entity_registry: er.EntityRegistry, ) -> None: """Test switch platform with fake data that creates 7 zones with one enabled.""" @@ -100,6 +102,10 @@ async def test_zones( assert not hass.states.get("switch.rain_bird_sprinkler_8") + # Verify unique id for one of the switches + entity_entry = entity_registry.async_get("switch.rain_bird_sprinkler_3") + assert entity_entry.unique_id == "1263613994342-3" + async def test_switch_on( hass: HomeAssistant, @@ -275,3 +281,29 @@ async def test_switch_error( with pytest.raises(HomeAssistantError, match=expected_msg): await switch_common.async_turn_off(hass, "switch.rain_bird_sprinkler_3") await hass.async_block_till_done() + + +@pytest.mark.parametrize( + ("config_entry_unique_id"), + [ + None, + ], +) +async def test_no_unique_id( + hass: HomeAssistant, + setup_integration: ComponentSetup, + aioclient_mock: AiohttpClientMocker, + responses: list[AiohttpClientMockResponse], + entity_registry: er.EntityRegistry, +) -> None: + """Test an irrigation switch with no unique id.""" + + assert await setup_integration() + + zone = hass.states.get("switch.rain_bird_sprinkler_3") + assert zone is not None + assert zone.attributes.get("friendly_name") == "Rain Bird Sprinkler 3" + assert zone.state == "off" + + entity_entry = entity_registry.async_get("switch.rain_bird_sprinkler_3") + assert entity_entry is None